@aethervtt/shard-project 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/docs.js ADDED
@@ -0,0 +1,1336 @@
1
+ import {
2
+ allAssetSchemas,
3
+ allDataTypes,
4
+ allImporters
5
+ } from "@aethervtt/shard-assets";
6
+ import { allComponents } from "@aethervtt/shard-core";
7
+ import { allGenerators } from "@aethervtt/shard-procgen";
8
+ import { saveJsonSchema } from "@aethervtt/shard-save";
9
+ import { prefabJsonSchema, sceneJsonSchema } from "@aethervtt/shard-scene";
10
+ import { BUILTIN_PLUGINS, manifestJsonSchema } from "./manifest.js";
11
+ const GENERATED_START = "<!-- shard:generated -->";
12
+ const GENERATED_END = "<!-- /shard:generated -->";
13
+ function fieldRow(name, schema) {
14
+ const type = describeType(schema);
15
+ const def = schema.default === void 0 ? "" : `\`${JSON.stringify(schema.default)}\``;
16
+ const range = [
17
+ schema.minimum !== void 0 ? `\u2265 ${schema.minimum}` : "",
18
+ schema.maximum !== void 0 ? `\u2264 ${schema.maximum}` : "",
19
+ schema["x-unit"] ? `${schema["x-unit"]}` : ""
20
+ ].filter(Boolean).join(", ");
21
+ const presets = schema["x-presets"] ? ` Presets: ${Object.keys(schema["x-presets"]).join(", ")}.` : "";
22
+ const description = `${schema.description ?? ""}${presets}`.replace(
23
+ /\|/g,
24
+ "\\|"
25
+ );
26
+ return `| \`${name}\` | ${type} | ${def} | ${range} | ${description} |`;
27
+ }
28
+ function describeType(schema) {
29
+ if (schema.anyOf) return schema.anyOf.map(describeType).join(" or ");
30
+ if (schema.enum) return schema.enum.map((v) => `\`"${v}"\``).join(" \\| ");
31
+ if (schema.type === "array") {
32
+ const n = schema.minItems === schema.maxItems && schema.minItems !== void 0 ? `${schema.minItems}` : "";
33
+ return `${describeType(schema.items ?? {})}[${n}]`;
34
+ }
35
+ if (schema.type === "object" && schema.properties && schema.properties.guid)
36
+ return schema["x-asset-type"] ? `${schema["x-asset-type"]} ref` : "asset ref";
37
+ return schema.type ?? "any";
38
+ }
39
+ function renderComponentCatalog(defs = allComponents()) {
40
+ const visible = defs.filter((d) => !d.name.startsWith("test/"));
41
+ const lines = [
42
+ "# Components",
43
+ "",
44
+ "Generated by `shard docs` from the registry. Use these names as keys under `components` in scene files.",
45
+ ""
46
+ ];
47
+ for (const def of visible) {
48
+ lines.push(`## \`${def.name}\``, "");
49
+ if (def.description) lines.push(def.description, "");
50
+ if (!def.serializable) lines.push("_Computed by the engine; never written in scene files._", "");
51
+ if (def.requires.length > 0)
52
+ lines.push(`Brings along: ${def.requires.map((r) => `\`${r.name}\``).join(", ")}.`, "");
53
+ const props = def.jsonSchema().properties ?? {};
54
+ if (Object.keys(props).length === 0) {
55
+ lines.push("Tag (no fields).", "");
56
+ continue;
57
+ }
58
+ lines.push("| Field | Type | Default | Range | Description |", "|---|---|---|---|---|");
59
+ for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
60
+ if (def.name === "core/Transform") {
61
+ lines.push(
62
+ "| `rotationEuler` | number[3] | | degrees | Scene files only: rotation as X, then Y, then Z degrees. |"
63
+ );
64
+ }
65
+ lines.push("");
66
+ }
67
+ return lines.join("\n");
68
+ }
69
+ function renderErrorCatalog(codes) {
70
+ const lines = [
71
+ "# Error codes",
72
+ "",
73
+ "Every engine error is a `ShardError` with one of these codes.",
74
+ ""
75
+ ];
76
+ lines.push("| Code | Source | Hint |", "|---|---|---|");
77
+ for (const c of [...codes].sort((a, b) => a.code.localeCompare(b.code))) {
78
+ lines.push(`| \`${c.code}\` | ${c.source} | ${(c.hint ?? "").replace(/\|/g, "\\|")} |`);
79
+ }
80
+ return `${lines.join("\n")}
81
+ `;
82
+ }
83
+ function renderAssetCatalog(importers = allImporters(), dataTypes = []) {
84
+ const projectImporters = new Set(dataTypes.map((d) => d.importer.name));
85
+ const visible = importers.filter(
86
+ (i) => !i.name.startsWith("test") && !projectImporters.has(i.name)
87
+ );
88
+ const lines = [
89
+ "# Assets",
90
+ "",
91
+ "Generated by `shard docs`. Files under the asset roots (`assetRoots` in shard.json) are imported",
92
+ "by extension. Each gets a `.meta` next to it with its guid and import settings; commit both.",
93
+ 'Reference assets in scenes by path: `{ "path": "materials/hull.material.json" }`, or a',
94
+ 'sub-asset with `#`: `{ "path": "assets/ship.glb#Mesh/Hull" }`.',
95
+ ""
96
+ ];
97
+ for (const importer of visible) {
98
+ lines.push(`## \`${importer.name}\``, "");
99
+ lines.push(`Files: ${importer.extensions.map((e) => `\`*${e}\``).join(", ")}`, "");
100
+ const props = importer.settings.jsonSchema().properties ?? {};
101
+ if (Object.keys(props).length === 0) {
102
+ lines.push("No import settings.", "");
103
+ continue;
104
+ }
105
+ lines.push("| Setting | Type | Default | Range | Description |", "|---|---|---|---|---|");
106
+ for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
107
+ lines.push("");
108
+ }
109
+ if (dataTypes.length > 0) {
110
+ lines.push(
111
+ "# Project data types",
112
+ "",
113
+ "Defined in `scripts/` with `project.dataAsset`. Write one JSON file per value, anywhere under an",
114
+ "asset root (by convention `data/<kind>/`). A file can start from another of the same type with",
115
+ '`"$extends": { "path": "..." }` and override fields. Reference them from components and other',
116
+ "data types with `t.handle('<type>')`; read them with `world.resource(Type.store).get(ref)`.",
117
+ ""
118
+ );
119
+ for (const type of dataTypes) {
120
+ lines.push(`## \`${type.name}\``, "");
121
+ if (type.description) lines.push(type.description, "");
122
+ lines.push(
123
+ `Files: \`*.${type.extension}.json\` (e.g. \`data/${type.extension}s/<name>.${type.extension}.json\`). Schema: \`.shard/schemas/${type.extension}.schema.json\`.`,
124
+ ""
125
+ );
126
+ const props = type.jsonSchema().properties ?? {};
127
+ if (Object.keys(props).length === 0) {
128
+ lines.push("No fields.", "");
129
+ continue;
130
+ }
131
+ lines.push("| Field | Type | Default | Range | Description |", "|---|---|---|---|---|");
132
+ for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
133
+ lines.push("");
134
+ }
135
+ }
136
+ return `${lines.join("\n")}
137
+ `;
138
+ }
139
+ function renderGeneratorCatalog(gens = allGenerators()) {
140
+ const visible = gens.filter((g) => !g.name.startsWith("test/"));
141
+ const lines = [
142
+ "# Generators",
143
+ "",
144
+ "Generated by `shard docs`. A generator is `(seed, params) \u2192 output`, cached by the hash of its",
145
+ "inputs (name, version, code, params, seed, loaded assets). Use an output as:",
146
+ "",
147
+ "- a file, `generators/<name>.gen.json` (schema `.shard/schemas/gen.schema.json`), usable anywhere a",
148
+ ' handle of the output type is: `{ "path": "generators/boulder.gen.json" }`',
149
+ '- a ref, `{ "path": "procedural:<generator>?radius=2&seed=3" }` (numbers, bools, asset paths)',
150
+ '- entities: `procgen/GeneratorInstance { "generator": { "path": "<generator>" }, "seed": 3, "params": {} }`',
151
+ "- at runtime: `await generate(world, Gen, params, seed)`",
152
+ "",
153
+ 'Tune one with MCP `preview_generator { "generator": "<name>", "seeds": "1-9" }` (a contact sheet)',
154
+ "and `run_generator` (counts, bounds, cache key). See `.agents/skills/make-a-generator.md`.",
155
+ ""
156
+ ];
157
+ if (visible.length === 0) lines.push("No generators yet.", "");
158
+ for (const gen of visible) {
159
+ lines.push(`## \`${gen.name}\``, "");
160
+ if (gen.description) lines.push(gen.description, "");
161
+ lines.push(
162
+ `Output: \`${gen.outputType}\`${gen.output === "mesh" ? " (sub-assets `#LOD1`, \u2026 when it makes levels of detail)" : ""}. Version ${gen.version}.`,
163
+ ""
164
+ );
165
+ const props = gen.params.jsonSchema().properties ?? {};
166
+ if (Object.keys(props).length === 0) lines.push("No params.", "");
167
+ else {
168
+ lines.push("| Param | Type | Default | Range | Description |", "|---|---|---|---|---|");
169
+ for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
170
+ lines.push("");
171
+ }
172
+ const example = {
173
+ $schema: "../.shard/schemas/gen.schema.json",
174
+ generator: gen.name,
175
+ seed: 1,
176
+ params: {}
177
+ };
178
+ lines.push("```json", JSON.stringify(example), "```", "");
179
+ }
180
+ return `${lines.join("\n")}
181
+ `;
182
+ }
183
+ const SKILLS = {
184
+ "build-a-scene.md": `# Build or change a scene
185
+
186
+ 1. Read \`.agents/components.md\` for component names and fields.
187
+ 2. Edit \`scenes/*.scene.json\`. Name every entity; nest with \`children\`. Materials go in \`assets\`
188
+ (\`{ "type": "Material", "value": {...} }\`), meshes can be procedural
189
+ (\`{ "path": "procedural:sphere?radius=2" }\`).
190
+ 3. Validate: \`shard validate --json\` (or the MCP \`validate_scene\` tool). Fix every error by its \`path\`.
191
+ 4. Look at it: \`shard screenshot scenes/main.scene.json --out shot.png\` (or MCP \`screenshot\`).
192
+ `,
193
+ "add-a-component.md": `# Add a component
194
+
195
+ In \`scripts/\`, define it on the project so it gets the project namespace:
196
+
197
+ \`\`\`ts
198
+ export const Fuel = project.component('Fuel', {
199
+ liters: t.f32({ default: 100, min: 0, unit: 'L', description: 'Fuel left.' }),
200
+ })
201
+ \`\`\`
202
+
203
+ Write a description for every field: it ends up in \`.agents/components.md\`. Run \`shard docs\` after.
204
+ `,
205
+ "write-a-system.md": `# Write a system
206
+
207
+ \`\`\`ts
208
+ const burn = defineSystem({
209
+ name: '<project>/burn',
210
+ setup: (world) => ({ q: world.query({ with: [Fuel] }) }),
211
+ run: ({ q }, world) => {
212
+ const dt = world.resource(FixedTime).step
213
+ for (const table of q.tables) {
214
+ const liters = table.column(Fuel, 'liters')
215
+ for (let i = 0; i < table.count; i++) liters[i] = Math.max(0, liters[i]! - dt)
216
+ table.markChanged(Fuel)
217
+ }
218
+ },
219
+ })
220
+ \`\`\`
221
+
222
+ Register it in the project's \`build(app)\`: \`app.addSystems(FixedUpdate, burn)\`. Hot loops must not
223
+ allocate: walk \`q.tables\` and use columns.
224
+ `,
225
+ "write-a-gameplay-test.md": `# Write a gameplay test
226
+
227
+ \`\`\`ts
228
+ import { expect, test } from '@aethervtt/shard-testing'
229
+
230
+ test('thrust moves the ship', async ({ game }) => {
231
+ await game.load('scenes/main.scene.json')
232
+ game.input.hold('<project>/Controls.thrust')
233
+ await game.step(120)
234
+ expect(game.get('ship', 'core/Transform').translation[2]).toBeLessThan(0)
235
+ })
236
+ \`\`\`
237
+
238
+ Run with \`shard test --json\`. Tests are headless, seeded, and fixed-timestep: they pass or fail the
239
+ same way every run.
240
+ `,
241
+ "iterate-on-a-system.md": `# Change game code and see it run
242
+
243
+ 1. Edit a system or component in \`scripts/\`. The running game (MCP server, \`shard dev\` page)
244
+ rebuilds and hot reloads on save, keeping every entity and resource.
245
+ 2. Check it took: MCP \`project_status\` (or the note in \`step\`'s result). A failed build or reload
246
+ reports \`source\` as \`scripts/<file>.ts:line:col\`, and the previous code keeps running.
247
+ 3. \`step\` some frames and \`screenshot\` or \`get_entity\` to see the effect.
248
+ 4. Run \`typecheck\` (\`shard check --json\`): reloads don't wait for type errors.
249
+ 5. Changing a component's fields migrates live data: new fields take their defaults, removed ones
250
+ are dropped. To rename a field, bump the component's \`version\` and move the value in \`migrate\`.
251
+ `,
252
+ "use-assets.md": `# Use asset files
253
+
254
+ 1. Put files under an asset root (\`assets/\`, \`materials/\`, \`data/\`). \`.agents/assets.md\` lists
255
+ what each extension imports as and its settings.
256
+ 2. Import: \`shard import --json\` (MCP \`reimport_asset\` with no "asset"). Fix failures by their \`path\`.
257
+ 3. Reference by path in scenes: \`"material": { "path": "materials/hull.material.json" }\`.
258
+ 4. Inspect with MCP \`get_asset\` (sub-assets, bounds, dependents, errors); change import settings
259
+ with \`reimport_asset\` and \`{ "settings": {...} }\`.
260
+ 5. Move or rename with \`shard mv <from> <to>\` (MCP \`move_asset\`), never by hand: it keeps the
261
+ \`.meta\` with the file and rewrites references.
262
+ 6. Look before you place: MCP \`preview_asset\` renders a texture, a material, or a model.
263
+
264
+ Models: place a .glb/.gltf with \`"scene/SceneInstance": { "scene": { "path": "assets/ship.glb#Scene" } }\`;
265
+ its nodes become children you can address by path (\`ship/Hull\`), changed per placement with
266
+ \`overrides\` (see make-a-prefab.md). Reusable entity trees are prefabs (\`prefabs/*.prefab.json\`).
267
+
268
+ Textures: a texture's \`usage\` setting says what it holds: \`color\` (albedo, emissive; sRGB),
269
+ \`data\` (roughness, metallic, occlusion; linear), \`normal\` (tangent-space normal map), \`hdr\`.
270
+ New files get a usage from their name (\`*_normal*\`, \`*rough*\`, \`*_orm*\`, \`*.hdr\`). Materials
271
+ use textures through slots: \`"baseColorTexture": { "texture": { "path": "assets/rock.png" } }\`
272
+ (also metallicRoughnessTexture, normalTexture, occlusionTexture, emissiveTexture).
273
+ `,
274
+ "write-a-material.md": `# Write a material
275
+
276
+ A material type is a schema plus a shader that overrides hooks. Lighting, shadows, and post effects
277
+ keep working because the shader only changes the surface.
278
+
279
+ 1. Define it in \`scripts/\`:
280
+
281
+ \`\`\`ts
282
+ export const Lava = project.material('Lava', {
283
+ fields: {
284
+ crackColor: t.color({ default: [1, 0.3, 0.05, 1] }),
285
+ crackLuminance: t.f32({ default: 20000, unit: 'cd/m\xB2' }),
286
+ flow: t.vec2({ default: [0.02, 0], description: 'UV scroll per second.' }),
287
+ cracks: t.handle('Texture', { description: 'Crack mask (R).' }),
288
+ },
289
+ shader: 'project::lava',
290
+ })
291
+ \`\`\`
292
+
293
+ 2. Write \`shaders/lava.wesl\`. The generated module \`material::lava\` has the uniform (\`Lava\`)
294
+ and one texture and sampler per texture field (\`Lava_cracks\`, \`Lava_cracks_sampler\`):
295
+
296
+ \`\`\`wesl
297
+ import shard::pbr::types::{ VertexOutput, PbrInput };
298
+ import shard::pbr::standard::standard_input;
299
+ import shard::globals::globals;
300
+ import material::lava::{ Lava, Lava_cracks, Lava_cracks_sampler };
301
+
302
+ override fn pbr_input(in: VertexOutput) -> PbrInput {
303
+ var p = standard_input(in);
304
+ let uv = in.uv + Lava.flow * globals.time;
305
+ let crack = textureSample(Lava_cracks, Lava_cracks_sampler, uv).r;
306
+ p.emissive += Lava.crackColor.rgb * Lava.crackLuminance * crack;
307
+ return p;
308
+ }
309
+ \`\`\`
310
+
311
+ Hooks: \`vertex_position(position, normal, uv) -> vec3f\` (object-space displacement, applied in
312
+ every pass, shadows included), \`pbr_input(in) -> PbrInput\` (the surface), and
313
+ \`fragment_output(color) -> vec4f\` (pre-exposed HDR, before tonemapping). With
314
+ \`extends: 'none'\`, override \`shade(in) -> vec4f\` instead: it returns radiance in cd/m\xB2 and
315
+ alpha, and no lighting runs. \`noise: { detail: 'assets/noise/rock.noise.json' }\` in the
316
+ definition adds \`noise_detail(p: vec3f, seed: u32) -> f32\` to \`material::lava\` (see
317
+ make-a-noise-graph.md). In \`vertex_position\`, \`shard::mesh\` also gives the vertex's
318
+ \`vertex_uv1()\` and \`vertex_tangent()\`, \`vertex_world(p)\` (object to world), and
319
+ \`vertex_instance_data()\`: the two numbers of the entity's \`render/InstanceData\` (a fade,
320
+ flags). \`arrays: ['layers']\` binds a texture field as \`texture_2d_array\` (a
321
+ \`*.texarray.json\`).
322
+
323
+ 3. Use it in a material file: \`{ "type": "<project>/Lava", "crackLuminance": 30000,
324
+ "cracks": { "path": "assets/cracks.png" } }\`. Leaving out \`"type"\` means the standard material.
325
+ 4. Check it: \`shard validate --json\` (bad fields are reported by path), then MCP \`preview_asset\`
326
+ on the material file. Shader errors appear in \`recent_errors\` as \`shaders/lava.wesl:line:col\`,
327
+ and the last shader that compiled keeps running.
328
+ `,
329
+ "make-2d.md": `# Make 2D: sprites, animation, tilemaps
330
+
331
+ Add \`"sprite"\` to \`plugins\` in \`shard.json\`. 2D uses the same renderer as 3D (HDR, post effects).
332
+
333
+ 1. An atlas. Either drop images in a folder and add \`assets/sprites/hero.atlas-pack.json\`
334
+ (\`{}\` packs \`assets/sprites/hero/*.png\`; regions are the file names), or write
335
+ \`hero.atlas.json\`: \`{ "texture": { "path": "assets/sheet.png" }, "grid": { "columns": 8,
336
+ "rows": 4, "cellWidth": 16, "cellHeight": 16 } }\` (regions \`cell0\`, \`cell1\`, \u2026) and/or
337
+ \`"regions": [{ "name": "idle_0", "rect": [x, y, w, h] }]\`.
338
+ 2. A sprite: \`"sprite/Sprite": { "atlas": { "path": "assets/sprites/hero.atlas-pack.json" },
339
+ "region": "idle_0", "layer": 1 }\`. No \`size\`: pixels \xF7 \`Sprite2dSettings.pixelsPerUnit\` (100).
340
+ Higher \`layer\` draws on top; within a layer, higher z does. \`"space": "screen"\` makes an
341
+ overlay positioned in pixels.
342
+ 3. Animation: \`walk.clip.json\` = \`{ "atlas": {...}, "frames": [{ "region": "walk_0",
343
+ "duration": 0.1 }, ...], "loop": "loop", "events": [{ "frame": 2, "name": "step" }] }\`, then
344
+ \`"sprite/SpriteAnimation": { "clip": { "path": "assets/walk.clip.json" } }\`. Read events with
345
+ \`world.reader(SpriteAnimationEvent)\`.
346
+ 4. Tilemaps: \`level.tilemap.json\` layers are base64 u16 tiles (atlas region + 1, 0 empty), and
347
+ \`"sprite/Tilemap": { "atlas": ..., "data": ..., "tileSize": [1, 1] }\`. Edit at runtime with
348
+ \`setTile(world, map, x, y, tile)\`; only that chunk re-uploads.
349
+ 5. Pixel art: \`"render/PixelPerfect": { "pixelsPerUnit": 16 }\` on an orthographic camera.
350
+ 6. Check: \`shard validate\`, MCP \`preview_asset\` on the atlas (regions outlined and numbered) or
351
+ the clip, and \`render.describe\` \u2192 \`sprites\` (draw calls, sprites per layer, tilemap chunks).
352
+ 7. Lights and shadows: see \`light-2d.md\`.
353
+ `,
354
+ "light-2d.md": `# Light 2D: lights, normal maps, shadows
355
+
356
+ Sprites are unlit until their camera has \`"sprite/Lighting2d": { "ambient": [0.08, 0.08, 0.1, 1] }\`.
357
+ Then each lit sprite shows \`albedo \xD7 (ambient + \u03A3 lights) + albedo \xD7 emissive\`: a light of
358
+ intensity 1 at its center shows the sprite at its texture color. For a bright scene where lights
359
+ only add, use \`"ambient": [1, 1, 1, 1]\`.
360
+
361
+ 1. Lights: \`"sprite/PointLight2d": { "color": [1, 0.7, 0.4, 1], "intensity": 1.5, "radius": 6 }\`.
362
+ \`"sprite/SpotLight2d"\` adds \`innerAngle\` / \`outerAngle\` (degrees) and points along the
363
+ entity's +X: rotate the entity (or parent it to a hand) to aim it. \`falloff\` tightens the
364
+ edge; \`height\` is how high above the sprites it sits (low grazes normal maps).
365
+ 2. Shadows: \`"shadows": true\` on the light (at most \`Lighting2d.maxShadowed\`, 64, per view; the
366
+ nearest win) and \`"sprite/LightOccluder2d"\` on what blocks it: \`"shape": "box"\` with \`size\`,
367
+ \`"circle"\` (\`size[0]\` is the radius), \`"polygon"\` with \`points\`, \`"sprite"\` (the sprite's
368
+ outline), or \`"collider"\` (its physics Collider). \`softness\` on the light widens penumbras
369
+ (0: hard). Tile walls: set \`"occludes": true\` on the layer in \`*.tilemap.json\`.
370
+ 3. Normal maps: name them \`hero_n.png\` next to \`hero.png\` in an atlas-pack folder and they pack
371
+ into a matching page (\`"normalMap": "directx"\` in the pack file flips green). Plain texture
372
+ sprites: \`"sprite/SpriteLighting": { "normal": { "path": "assets/rock_n.png" } }\`. Add
373
+ \`"outlines": true\` to the pack file for \`shape: 'sprite'\` occluders that follow the alpha.
374
+ 4. Glow and exceptions: \`SpriteLighting.emissive\` for eyes and screens; \`"lit": false\` on a Sprite
375
+ (flames, UI, particles that shine on their own). \`layers\` on lights and occluders masks which
376
+ layer bands they touch (bit k covers layers k\xD764 \u2212 1024 \u2026).
377
+ 5. Check: \`render.describe\` \u2192 \`sprites.lighting\` (lights lit, culled, dropped over budget,
378
+ shadowed and demoted, occluder segments, tiles at the 64-light cap, GPU ms), the \`lights2d\`
379
+ overlay in a screenshot (radii, cones, each shadow row as a ring, occluder segments), and the log
380
+ for \`sprite/invalid-occluder\`, \`sprite/too-many-lights\`, \`sprite/too-many-shadowed-lights\`.
381
+ `,
382
+ "show-text.md": `# Show text
383
+
384
+ Add \`"text"\` to \`plugins\` in \`shard.json\` and put a \`.ttf\` or \`.otf\` under \`assets/\` (import
385
+ settings: \`charset\`, \`size\`, \`range\`, \`fallback\` fonts for missing characters).
386
+
387
+ - In the world: \`"text/Text": { "value": "Scanner 7", "font": { "path": "assets/fonts/Inter.ttf" },
388
+ "size": 0.5, "billboard": true }\`. \`size\` is the em height in meters.
389
+ - On screen: \`"text/ScreenText": { "value": "Fuel 82%", "font": ..., "size": 24, "corner":
390
+ "top-right", "position": [-16, 16] }\` (pixels from the corner, y down).
391
+ - Style: \`outline: { width: 0.03, color }\`, \`shadow: { offset: [0.05, -0.05], softness: 0.3, color }\`
392
+ (offset 0 with softness is a glow), \`weight\`. Wrap with \`maxWidth\`; \`align\` and \`anchor\` place it.
393
+ - Size before placing: MCP \`measure_text\` returns width, height, and lines. \`preview_asset\` on the
394
+ font shows a specimen. Missing characters: \`render.describe\` \u2192 \`text.missing\`.
395
+ `,
396
+ "add-physics.md": `# Add physics
397
+
398
+ Add \`"physics3d"\` (or \`"physics2d"\` for a 2D game, never both) to \`plugins\` in \`shard.json\`.
399
+ Bodies and colliders are components, so scenes, prefabs, and \`patch_entity\` create and change them:
400
+
401
+ \`\`\`json
402
+ { "name": "crate", "components": {
403
+ "core/Transform": { "translation": [0, 3, 0] },
404
+ "physics/RigidBody": { "kind": "dynamic" },
405
+ "physics/Collider": { "shape": "cuboid", "halfExtents": [0.5, 0.5, 0.5], "restitution": 0.2 } } }
406
+ \`\`\`
407
+
408
+ - Kinds: \`dynamic\` (forces and contacts move it), \`fixed\`, \`kinematic-position\` (follows its
409
+ Transform), \`kinematic-velocity\` (moves by \`physics/Velocity\`). A \`Collider\` without a body is fixed.
410
+ - Shapes: ball (\`radius\`), cuboid (\`halfExtents\`), capsule, cylinder, cone (\`radius\`, \`halfHeight\`
411
+ along Y), convex and trimesh (\`mesh\`: a Mesh asset, or \`points\`), heightfield, segment, polyline.
412
+ Scale in the Transform scales the shape. Colliders on children without their own body attach to
413
+ the nearest ancestor body (one compound body).
414
+ - Move bodies with \`physics/Velocity\`, \`physics/ExternalForce\` (every step), or
415
+ \`physics/ExternalImpulse\` (once). Writing a dynamic body's Transform teleports it.
416
+ - Layers: \`layers\` and \`mask\` are 16-bit masks; two colliders touch when each one's mask has the
417
+ other's layer. Planets: \`physics/GravitySource\` on the planet, and \`physics/Config\` gravity \`[0, 0, 0]\`.
418
+ - Events: set \`events: true\` on a collider (sensors too) and read \`physics/CollisionEvent\`
419
+ (\`started\` / \`stopped\`, the two collider entities and their bodies).
420
+ - Queries in code: \`world.resource(Physics).raycast(origin, dir, { mask }, hit)\`. From tools:
421
+ \`physics_raycast\`, \`physics_overlap\`, \`physics_describe\`. See shapes with
422
+ \`screenshot\` \`"overlays": ["colliders"]\`.
423
+
424
+ Check a fall with a gameplay test:
425
+
426
+ \`\`\`ts
427
+ test('the crate lands', async ({ game }) => {
428
+ await game.load('scenes/main.scene.json')
429
+ await game.step(120)
430
+ expect(game.get('crate', 'core/Transform').translation[1]).toBeCloseTo(0.5, 1)
431
+ })
432
+ \`\`\`
433
+
434
+ ## Characters
435
+
436
+ A walking player or NPC is \`physics/CharacterController\` with no RigidBody or Collider: it makes
437
+ its own kinematic capsule (centered on the entity), climbs steps and slopes, snaps to the ground,
438
+ pushes dynamic bodies, and rides kinematic platforms.
439
+
440
+ \`\`\`json
441
+ { "name": "player", "components": {
442
+ "core/Transform": { "translation": [0, 1, 0] },
443
+ "physics/CharacterController": { "height": 1.8, "radius": 0.35, "up": "gravity" } } }
444
+ \`\`\`
445
+
446
+ - Write \`physics/CharacterIntent\`: \`move\` in m/s in the character's frame (x right, -z forward;
447
+ 2D: x), and \`jump: true\` to jump once at the next grounded step.
448
+ - Read \`physics/CharacterState\` (\`grounded\`, \`groundEntity\`, \`velocity\`, \`up\`, \`airTime\`) and
449
+ \`physics/CharacterGroundEvent\` (landed or left the ground). \`physics_describe\` lists characters.
450
+ - \`up: "gravity"\` follows the strongest \`physics/GravitySource\` (walk around planets) and turns the
451
+ entity to match; \`"fixed"\` uses \`fixedUp\` and \`gravity\`. Turn it by writing its rotation;
452
+ writing its translation teleports it.
453
+
454
+ \`\`\`ts
455
+ test('the player walks and lands', async ({ game }) => {
456
+ await game.load('scenes/main.scene.json')
457
+ await game.step(30)
458
+ await game.patch('player', { 'physics/CharacterIntent': { move: [0, 0, -3] } })
459
+ await game.step(60)
460
+ expect(game.get('player', 'physics/CharacterState').grounded).toBe(true)
461
+ expect(game.get('player', 'core/Transform').translation[2]).toBeLessThan(-2.5)
462
+ })
463
+ \`\`\`
464
+ `,
465
+ "animate.md": `# Animate a model or a property
466
+
467
+ Add \`"animation"\` to \`plugins\` in \`shard.json\`. A \`.glb\` with animations imports each as an
468
+ \`AnimationClip\` sub-asset (\`assets/ships/walker.glb#Animation/Walk\`; MCP \`get_asset\` lists its
469
+ channels and duration). Skinned meshes and morph targets import tagged (\`render/SkinnedMesh\`,
470
+ \`render/MorphWeights\`) and deform on their own; the player moves the joints.
471
+
472
+ \`\`\`json
473
+ { "name": "walker", "components": {
474
+ "core/Transform": {},
475
+ "scene/SceneInstance": { "scene": { "path": "assets/walker.glb#Scene" } },
476
+ "animation/AnimationPlayer": { "layers": [
477
+ { "clip": { "path": "assets/walker.glb#Animation/Walk" } },
478
+ { "clip": { "path": "assets/walker.glb#Animation/Wave" }, "weight": 1,
479
+ "mask": { "path": "assets/masks/upper-body.mask.json" } } ] } } }
480
+ \`\`\`
481
+
482
+ - Put the player on the model root (the SceneInstance entity). Channels bind joints by path under
483
+ it; \`animation_describe\` lists channels that found no target.
484
+ - Layers blend in order: \`override\` lerps toward its pose by \`weight\`; \`additive\` adds its change
485
+ since the clip's first frame. \`loop\`: \`loop\`, \`once\` (holds the end, sends
486
+ \`animation/AnimationFinished\`), \`ping-pong\`. Seek with \`time\`, pause with \`playing: false\`.
487
+ - A mask is \`{ "joints": { "Armature/Hips/Spine": 1, "Armature/Hips/Spine/Neck": 0 } }\` in a
488
+ \`*.mask.json\`: a path covers its subtree, the longest match wins, uncovered joints get 0.
489
+ - In code: \`crossfade(world, entity, clipRef, 0.3, { loop: 'once' })\` fades in a layer and fades
490
+ the others out; \`animationLayer(clipRef, { weight: 0.5 })\` builds a full layer.
491
+ - To pick clips from state (idle, walk, run, jump, land), use a graph: see
492
+ \`animate-a-character.md\`.
493
+ - Root motion: \`"rootMotion": "character"\` on an entity with \`physics/CharacterController\` turns
494
+ the clip's root travel into \`CharacterIntent.move\` (walking collides); \`"transform"\` moves the
495
+ entity directly. The root joint stays in place; \`animation/RootMotion\` has this frame's delta.
496
+ - See joints with \`screenshot\` \`"overlays": ["skeleton"]\`; \`preview_asset\` on a glTF clip shows
497
+ its model at 5 times.
498
+ - Feet on uneven ground, heads that look at things, weapons in hands, or clips from another rig:
499
+ see \`rig-a-character.md\`.
500
+
501
+ Property clips animate any numeric field by path (\`""\` is the player's entity):
502
+
503
+ \`\`\`json
504
+ { "duration": 2,
505
+ "tracks": [
506
+ { "path": "radar/dish", "component": "core/Transform", "field": "rotation",
507
+ "keys": [[0, [0, 0, 0, 1]], [2, [0, 1, 0, 0]]] },
508
+ { "path": "", "component": "render/PointLight", "field": "intensity",
509
+ "keys": [[0, 800], [0.1, 0], [0.2, 800]], "interpolation": "step" } ],
510
+ "events": [{ "time": 1, "name": "ping" }] }
511
+ \`\`\`
512
+
513
+ Save it as \`assets/anims/radar.anim.json\`. Events arrive as \`animation/AnimationEvent\`
514
+ (\`name\`, \`layer\`, \`time\`). Unknown components or fields fail import with a pointer.
515
+ `,
516
+ "animate-a-character.md": `# Animate a character with a graph
517
+
518
+ A graph (\`*.animgraph.json\`) picks the character's clips from parameters: states, transitions
519
+ with conditions, blend spaces, and masked layers. It writes the \`animation/AnimationPlayer\`
520
+ layers each frame, so everything the player does (masks, root motion, events) still works.
521
+
522
+ 1. Import the model (\`assets/hero.glb\`); \`get_asset\` lists its \`#Animation/...\` clips.
523
+ 2. Write \`data/hero.animgraph.json\` (schema \`.shard/schemas/animgraph.schema.json\`):
524
+
525
+ \`\`\`json
526
+ { "parameters": {
527
+ "speed": { "type": "float", "bind": { "component": "physics/CharacterState", "field": "velocity", "op": "horizontal" } },
528
+ "grounded": { "type": "bool", "bind": { "component": "physics/CharacterState", "field": "grounded" } },
529
+ "attack": { "type": "trigger" } },
530
+ "layers": [
531
+ { "name": "base", "entry": "locomotion",
532
+ "states": {
533
+ "locomotion": { "blend1d": { "parameter": "speed", "clips": [[0, "#idle"], [1.5, "#walk"], [5, "#run"]] } },
534
+ "fall": { "clip": "#fall" },
535
+ "land": { "clip": "#land", "loop": "once" } },
536
+ "transitions": [
537
+ { "from": "locomotion", "to": "fall", "when": "!grounded", "duration": 0.15 },
538
+ { "from": "fall", "to": "land", "when": "grounded", "duration": 0.05 },
539
+ { "from": "land", "to": "locomotion", "exitTime": 0.8, "duration": 0.2 } ] },
540
+ { "name": "upper", "mask": { "path": "data/masks/upper-body.mask.json" },
541
+ "states": { "none": {}, "swing": { "clip": "#swing", "loop": "once" } },
542
+ "transitions": [
543
+ { "from": "none", "to": "swing", "when": "attack", "duration": 0.1 },
544
+ { "from": "swing", "to": "none", "exitTime": 1, "duration": 0.2 } ] } ],
545
+ "clips": {
546
+ "idle": { "path": "assets/hero.glb#Animation/Idle" }, "walk": { "path": "assets/hero.glb#Animation/Walk" },
547
+ "run": { "path": "assets/hero.glb#Animation/Run" }, "fall": { "path": "assets/hero.glb#Animation/Fall" },
548
+ "land": { "path": "assets/hero.glb#Animation/Land" }, "swing": { "path": "assets/hero.glb#Animation/Swing" } } }
549
+ \`\`\`
550
+
551
+ 3. Put \`"animation/Animator": { "graph": { "path": "data/hero.animgraph.json" } }\` on the model
552
+ root (next to its AnimationPlayer). Bound parameters read the component on that entity or its
553
+ nearest ancestor, so a model under a \`physics/CharacterController\` entity finds its state.
554
+ 4. \`shard validate --json\`: unknown states, clips, or parameters and conditions that don't parse
555
+ are errors with a pointer (conditions give the column); unreachable states are warnings.
556
+
557
+ - Conditions: parameter names, numbers, \`true\`/\`false\`, \`!\`, \`&&\`, \`||\`, \`< <= > >= == !=\`,
558
+ parentheses. Transitions are checked in order, first match wins; \`"from": "*"\` is any state.
559
+ \`exitTime\` waits for the source state's normalized time (1 = its end); \`duration\` crossfades.
560
+ - Triggers stay true until a transition that reads them is taken. Set parameters in code with
561
+ \`setAnimParam(world, entity, 'attack', true)\`, or patch \`animation/AnimatorParams.values\`.
562
+ - \`{}\` states play nothing: the layers below show through. \`blend1d\` syncs its clips' normalized
563
+ times; \`blend2d\` (\`"x"\`, \`"y"\`, \`"clips": [[x, y, "#clip"], ...]\`) weights the three nearest
564
+ samples. Bind ops: \`value\`, \`length\`, \`horizontal\` (xz), \`x\`, \`y\`, \`z\`, \`not\`.
565
+ - \`animation/AnimatorStateEntered\` (\`entity\`, \`layer\`, \`state\`, \`from\`) fires on each entry, for
566
+ "landed" or "swing hit". \`animation_describe\` shows each layer's state, transition progress,
567
+ time in state, blend weights, and the parameters.
568
+
569
+ \`\`\`ts
570
+ test('the hero falls, lands, and walks', async ({ game }) => {
571
+ await game.load('scenes/main.scene.json')
572
+ const layer = async () =>
573
+ (await game.call<any>('animation.describe', { entity: 'hero/model' })).animator.layers[0]
574
+ await game.step(30)
575
+ expect((await layer()).state).toBe('locomotion')
576
+ await game.patch('hero', { 'core/Transform': { translation: [0, 5, 0] } })
577
+ await game.step(10)
578
+ expect((await layer()).state).toBe('fall')
579
+ await game.patch('hero', { 'physics/CharacterIntent': { move: [0, 0, -3] } })
580
+ await game.step(120) // lands, then walks at 3 m/s: between walk (1.5) and run (5)
581
+ const base = await layer()
582
+ expect(base.state).toBe('locomotion')
583
+ expect(base.active.at(-1).motions.find((m: any) => m.name === 'Walk').weight).toBeGreaterThan(0.5)
584
+ })
585
+ \`\`\`
586
+ `,
587
+ "rig-a-character.md": `# IK, sockets, and retargeting
588
+
589
+ IK runs on the animated pose each frame (after the clips, before rendering). Joint fields are
590
+ **paths** under the IK entity or its nearest ancestor that has them, like animation channels, so
591
+ put IK on the model root (or on an entity under it, one per leg). Targets and poles are entities.
592
+
593
+ \`\`\`json
594
+ { "name": "hero", "components": {
595
+ "scene/SceneInstance": { "scene": { "path": "assets/hero.glb#Scene" },
596
+ "overrides": { "Armature/Hips/Spine/Chest/Shoulder_R/UpperArm_R/LowerArm_R/Hand_R": {
597
+ "animation/BoneSocket": { "name": "hand_r", "offset": [0, -0.07, 0] } } } },
598
+ "animation/AnimationPlayer": { "layers": [{ "clip": { "path": "assets/hero.glb#Animation/Walk" } }] },
599
+ "animation/FootPlacement": { "hips": "Armature/Hips", "maxStep": 0.4, "feet": [
600
+ { "ik": "hero/leg-l", "footJoint": "Armature/Hips/UpLeg_L/Leg_L/Foot_L", "offset": 0.08 },
601
+ { "ik": "hero/leg-r", "footJoint": "Armature/Hips/UpLeg_R/Leg_R/Foot_R", "offset": 0.08 } ] },
602
+ "animation/LookAtIk": { "joint": "Armature/Hips/Spine/Chest/Neck/Head", "target": "player",
603
+ "maxAngle": 70, "chain": [{ "joint": "Armature/Hips/Spine/Chest", "share": 0.2 }] } },
604
+ "children": [
605
+ { "name": "leg-l", "components": { "animation/TwoBoneIk": {
606
+ "root": "Armature/Hips/UpLeg_L", "mid": "Armature/Hips/UpLeg_L/Leg_L",
607
+ "tip": "Armature/Hips/UpLeg_L/Leg_L/Foot_L", "target": "hero/foot-l", "pole": "hero/knee-l",
608
+ "tipRotation": 1 } } },
609
+ { "name": "foot-l", "components": { "core/Transform": {} } },
610
+ { "name": "knee-l", "components": { "core/Transform": { "translation": [-0.1, 0.5, -1] } } } ] }
611
+ \`\`\`
612
+
613
+ (and the same for the right leg). Add a sword with
614
+ \`"animation/Attach": { "owner": "hero", "socket": "hand_r" }\`: it rides the hand, and comes back
615
+ to it when the model reloads.
616
+
617
+ - \`TwoBoneIk\`: legs and arms; bends toward \`pole\`. \`ChainIk\`: FABRIK for tails and tentacles.
618
+ \`LookAtIk\`: \`axis\` is the joint's own forward (-Z by default), clamped to \`maxAngle\` degrees.
619
+ - \`FootPlacement\` needs \`"physics"\` in plugins. It casts down from each foot along the entity's
620
+ up, lowers the hips (never raises), and moves each leg's target onto the ground, turned to its
621
+ normal. Put the entity level with the soles; \`offset\` is the ankle height.
622
+ - \`weight\` (0 to 1) blends every solver with the animation: fade it for cutscenes or ragdolls.
623
+ - Retarget clips made for another skeleton: \`"animation/Retarget": { "source": { "path":
624
+ "assets/mixamo.glb#Skin/Skin0" }, "mode": "rotation-and-root" }\` on the player. Names match after
625
+ normalizing (\`mixamorig:LeftUpLeg\` = \`UpLeg_L\`); others go in a \`*.jointmap.json\`
626
+ (\`{ "joints": { "Spine1": "Chest" } }\`, the \`map\` field).
627
+ - \`animation_describe\` on the model: \`ik\` (distance left to each target, foot hits, hips drop,
628
+ problems like \`ik/unknown-joint\`) and \`retarget\` (hip ratio, unmapped joints). Screenshot with
629
+ \`"overlays": ["ik", "skeleton"]\` to see targets, poles, and foot rays.
630
+ `,
631
+ "make-a-data-asset.md": `# Make a data asset
632
+
633
+ Tuning data that isn't an entity (weapon stats, items, loot tables, wave schedules) is a project
634
+ data type: fields in \`scripts/\`, one JSON file per value.
635
+
636
+ 1. **Define the type** with component-schema fields:
637
+
638
+ \`\`\`ts
639
+ export const Weapon = project.dataAsset('Weapon', {
640
+ damage: t.f32({ default: 10, min: 0, unit: 'hp', description: 'Damage per hit.' }),
641
+ fireRate: t.f32({ default: 4, unit: 'shots/s' }),
642
+ projectile: t.handle('Prefab', { description: 'Spawned per shot.' }),
643
+ upgradesTo: t.handle('<project>/Weapon'),
644
+ }, { extension: 'weapon', description: 'A ship weapon.' })
645
+ \`\`\`
646
+
647
+ Run \`shard docs\`: the type shows up in \`.agents/assets.md\` and its schema is written to
648
+ \`.shard/schemas/weapon.schema.json\`.
649
+ 2. **Write files**: \`data/weapons/laser.weapon.json\`. Missing fields take their defaults.
650
+
651
+ \`\`\`json
652
+ { "$schema": "../../.shard/schemas/weapon.schema.json", "damage": 12,
653
+ "projectile": { "path": "prefabs/bolt.prefab.json" } }
654
+ \`\`\`
655
+ 3. **Make a variant** that starts from another file and overrides fields (struct fields merge field
656
+ by field, lists and handles replace):
657
+
658
+ \`\`\`json
659
+ { "$extends": { "path": "data/weapons/laser.weapon.json" }, "damage": 18 }
660
+ \`\`\`
661
+
662
+ Editing the base re-imports its variants. MCP \`get_asset\` on a variant shows its value, its
663
+ \`extends\` chain, and which file set each field (\`info.setBy\`).
664
+ 4. **Reference it** from a component: \`project.component('Armed', { weapon: t.handle('<project>/Weapon') })\`,
665
+ and in a scene \`"<project>/Armed": { "weapon": { "path": "data/weapons/laser.weapon.json" } }\`.
666
+ The weapon loads with the scene.
667
+ 5. **Read it** in a system: \`world.resource(Weapon.store).get(armed.weapon)\` is one map lookup,
668
+ no allocation. Every weapon at once: \`await loadAll(world, Weapon)\` (from \`@aethervtt/shard-assets\`).
669
+ 6. **Check**: \`shard import --json\` then \`shard validate --json\`. Errors point into the file;
670
+ a handle to the wrong type is \`schema/asset-type-mismatch\`, a loop of variants \`data/extends-cycle\`.
671
+
672
+ Saving a data file or changing the type's fields while the game runs reloads the values in place.
673
+ `,
674
+ "make-a-prefab.md": `# Make a prefab
675
+
676
+ A prefab is a reusable entity tree: \`prefabs/<name>.prefab.json\`, validated against
677
+ \`.shard/schemas/prefab.schema.json\`. It has one \`root\` entity, written like a scene entity:
678
+
679
+ \`\`\`json
680
+ { "$schema": "../.shard/schemas/prefab.schema.json", "version": 1,
681
+ "assets": { "hull": { "type": "Material", "value": { "baseColor": "#8a93a6" } } },
682
+ "root": { "name": "ship",
683
+ "components": { "core/Transform": {}, "<project>/Health": { "max": 100 } },
684
+ "children": [
685
+ { "name": "Hull", "components": { "scene/SceneInstance": { "scene": { "path": "assets/ship.glb#Scene" } } } },
686
+ { "name": "Exhaust", "components": { "particles/ParticleSystem": { "effect": { "path": "assets/fx/exhaust.particles.json" } } } }
687
+ ] } }
688
+ \`\`\`
689
+
690
+ 1. **Extract**: move an entity and its children out of a scene into \`root\`. Entity fields inside
691
+ the prefab use paths from the root (\`"Hull"\`, \`"Hull/Cockpit"\`; \`"."\` is the root).
692
+ \`shard import\` then \`shard validate --json\`: errors point into the file.
693
+ 2. **Place** it in a scene. The instance entity *is* the root: the prefab's root components merge
694
+ into it, and fields the entity sets win. Its children are generated, at \`player-ship/Exhaust\`:
695
+
696
+ \`\`\`json
697
+ { "name": "player-ship", "components": {
698
+ "core/Transform": { "translation": [0, 5, 0] },
699
+ "scene/PrefabInstance": { "prefab": { "path": "prefabs/ship.prefab.json" },
700
+ "overrides": {
701
+ "Exhaust": { "particles/ParticleSystem": { "timeScale": 2 } },
702
+ "Hull/Cockpit": { "render/MeshMaterial": { "material": { "path": "materials/red.material.json" } } },
703
+ "Hull/Antenna": null,
704
+ "Exhaust/particles/ParticleEmitterOverrides": null } } } }
705
+ \`\`\`
706
+
707
+ Override keys are paths from the instance and reach into models inside it (\`Hull/Cockpit\`).
708
+ A patch sets fields (adding the component if missing), \`null\` removes the entity, and a path
709
+ ending in a component name set to \`null\` removes that component. Authored \`children\` of the
710
+ instance sit next to the generated ones (names can't clash). \`scene/SceneInstance\` takes the same
711
+ \`overrides\` for a model placed directly.
712
+ 3. **Override live**: \`patch_entity\` on \`player-ship/Exhaust\`, check \`prefab_overrides\`, then
713
+ \`save_scene\` with \`"write": true\`: the change is written into \`overrides\`, nothing else.
714
+ \`prefab.apply\` (protocol) moves an instance's overrides into the prefab file instead.
715
+ 4. **Variant**: a prefab that extends another. Editing the base updates every variant.
716
+
717
+ \`\`\`json
718
+ { "version": 1, "extends": { "path": "prefabs/ship.prefab.json" },
719
+ "rootComponents": { "<project>/Health": { "max": 250 } },
720
+ "overrides": { "Hull/Antenna": null },
721
+ "children": [{ "name": "Turret", "components": { "core/Transform": {} } }] }
722
+ \`\`\`
723
+ 5. **Spawn from code**: load once, then spawning copies a compiled template (cheap enough for
724
+ bullets). In a system, pass \`ctx.commands\` to spawn when the commands apply:
725
+
726
+ \`\`\`ts
727
+ import { loadPrefab, spawnPrefab } from '@aethervtt/shard-scene'
728
+ await loadPrefab(world, 'prefabs/ship.prefab.json') // e.g. in the plugin's setup
729
+ const ship = spawnPrefab(ctx.commands, 'prefabs/ship.prefab.json', {
730
+ transform: { translation: [0, 5, 0] },
731
+ overrides: { Exhaust: { 'particles/ParticleSystem': { timeScale: 2 } } },
732
+ })
733
+ \`\`\`
734
+
735
+ Unloaded prefabs throw \`prefab/not-loaded\`. MCP \`spawn_prefab\` does the same from tools.
736
+
737
+ Saving a prefab file updates every running instance and keeps each one's overrides; an override
738
+ whose path no longer exists is kept in the file and logged as \`prefab/stale-override\`.
739
+ \`get_asset\` on a prefab shows its entity tree, and on a variant its base chain.
740
+ `,
741
+ "add-sound.md": `# Add sound
742
+
743
+ Add \`"audio"\` to \`plugins\` in \`shard.json\`. Drop \`.ogg\`, \`.mp3\`, \`.wav\`, or \`.flac\` files under
744
+ \`assets/\` and run \`shard import\`: each is an \`AudioClip\` (MCP \`get_asset\` shows duration, channels,
745
+ sample rate, codec). Files stay compressed. Clips under \`music/\` stream; set \`"mode": "stream"\` in
746
+ the \`.meta\` for other long files, and \`loopStart\`/\`loopEnd\` (seconds) for loop points.
747
+
748
+ \`\`\`json
749
+ { "name": "camera", "components": { "core/Transform": {}, "render/Camera3d": {},
750
+ "audio/AudioListener": {} } },
751
+ { "name": "reactor", "components": { "core/Transform": { "translation": [4, 0, -10] },
752
+ "audio/AudioSource": { "clip": { "path": "assets/sfx/hum.ogg" }, "loop": true,
753
+ "rolloff": "linear", "maxDistance": 30 } } },
754
+ { "name": "music", "components": { "core/Transform": {},
755
+ "audio/AudioSource": { "clip": { "path": "assets/music/theme.ogg" }, "bus": "music",
756
+ "loop": true, "spatial": false, "volume": 0.6 } } }
757
+ \`\`\`
758
+
759
+ - Sources autoplay; set \`playing\` to start or stop one. A clip that doesn't loop clears
760
+ \`playing\` at its end and sends \`audio/AudioFinished\`. Spatial sources follow their transform;
761
+ the listener is the first \`audio/AudioListener\` (put it on the camera).
762
+ - \`rolloff\`: \`inverse\` (default, natural), \`linear\` (silent at \`maxDistance\`; past it the source
763
+ goes virtual and holds no voice), \`exponential\`. \`panning\`: \`equal-power\` or \`hrtf\`.
764
+ - One-shots from code: \`playSound(world, weapon.sound, { position: muzzle, bus: 'sfx' })\` from
765
+ \`@aethervtt/shard-audio\` returns a voice id for \`stopSound\`. A handle field
766
+ (\`t.handle('AudioClip')\`) on a data asset keeps sounds in data.
767
+ - Buses are data (\`audio/Buses\`: master, music, sfx, ui, voice). Add one in a scene's
768
+ \`"resources": { "audio/Buses": { "engines": { "volume": 0.7, "parent": "sfx" } } }\`; change them
769
+ with \`setBus(world, 'sfx', { muted: true })\`. \`duck(world, 'music', { by: 0.7, release: 0.5 })\`
770
+ lowers music while a voice-bus line plays.
771
+ - Over 64 voices (8 per clip) the lowest \`priority\`, then the quietest, lose theirs.
772
+ - Check it: \`audio_describe\` lists every voice with gain and pan (-1 left, +1 right); gameplay tests
773
+ read \`await game.audioLog()\`:
774
+
775
+ \`\`\`ts
776
+ expect(await game.audioLog()).toContainEqual(
777
+ expect.objectContaining({ event: 'start', clip: 'assets/sfx/laser.ogg', position: [0, 0, 0] }),
778
+ )
779
+ \`\`\`
780
+
781
+ Browsers start audio after the first click or key press; until then \`audio_describe\` says
782
+ \`"context": "suspended"\` and voices wait. Headless runs record voices without sound.
783
+ `,
784
+ "make-a-hud.md": `# Make a HUD
785
+
786
+ Add \`"ui"\` to \`plugins\` in \`shard.json\`. UI is entities: a \`ui/UiRoot\` fills the screen, and
787
+ \`ui/UiNode\` children lay out by flexbox (\`direction\`, \`justify\`, \`alignItems\`, \`grow\`, \`gap\`,
788
+ \`padding\` and \`margin\` as top, right, bottom, left). Sizes are pixels, \`"50%"\`, or \`"auto"\`.
789
+ Make the HUD a prefab, \`prefabs/hud.prefab.json\`, and place it in the scene with
790
+ \`scene/PrefabInstance\`:
791
+
792
+ \`\`\`json
793
+ { "version": 1, "root": { "name": "hud", "components": {
794
+ "ui/UiRoot": { "scale": "fit-height", "referenceSize": [1920, 1080],
795
+ "theme": { "path": "ui/hud.theme.json" } },
796
+ "ui/UiNode": { "padding": [24, 24, 24, 24], "justify": "space-between" } },
797
+ "children": [
798
+ { "name": "fuel", "components": { "ui/UiNode": { "style": "panel", "width": 260 } },
799
+ "children": [{ "name": "label", "components": { "ui/UiText": { "text": "Fuel 100%" } } }] },
800
+ { "name": "scan", "components": { "ui/UiNode": { "style": "button", "padding": [8, 16, 8, 16] },
801
+ "ui/UiButton": {} },
802
+ "children": [{ "name": "label", "components": { "ui/UiText": { "text": "Scan" } } }] }
803
+ ] } }
804
+ \`\`\`
805
+
806
+ - Looks: \`ui/UiStyle\` (background, borderColor, borderWidth, radius, opacity), \`ui/UiText\`
807
+ (text, size, color, font, align, wrap), \`ui/UiImage\` (texture or atlas region, \`slice\` for
808
+ nine-slice, \`fit\`). A \`*.theme.json\` names styles; nodes pick one with \`UiNode.style\`, and
809
+ \`"button:hovered"\` (on, focused, hovered, pressed, disabled) overrides a state. Fields a node sets
810
+ win over its theme. Saving the theme restyles the running game.
811
+ - Text needs a font: \`UiText.font\`, the theme's \`font\`, or the \`ui/UiDefaults\` resource.
812
+ - Widgets: \`ui/UiButton\`, \`ui/UiToggle\` (\`on\`), \`ui/UiSlider\` (\`value\`, \`min\`, \`max\`, \`step\`),
813
+ \`ui/UiTextInput\` (\`value\`, shown through its UiText). Systems read \`ui/UiClick\` and
814
+ \`ui/UiChanged\` events. A click on UI never reaches gameplay actions or world picking.
815
+ - Bind text from a system: \`world.set(label, UiText, { text: \\\`Fuel \\\${pct}%\\\` })\`. Setting the
816
+ same text again costs nothing.
817
+ - Markers over planets: a node with \`ui/UiAnchor\` (\`target\` entity path, \`clamp\`, \`hideBehind\`,
818
+ \`scaleDistance\`) and an optional \`ui/UiAnchorArrow\` child that points at an off-screen target.
819
+ - Keyboard and gamepad: Tab moves focus; arrows and the D-pad move it; Enter, Space, or south
820
+ presses. Focus a menu's first button with \`ui_focus\` or \`focusUi(world, entity)\`.
821
+
822
+ Check it as data, not pixels: \`ui_describe\` gives each node's rect, text, and state, and
823
+ \`ui_click\` presses a button by path. In gameplay tests:
824
+
825
+ \`\`\`ts
826
+ await game.ui.click('hud/scan')
827
+ await game.step(1)
828
+ expect(await game.ui.node('fuel/label')).toMatchObject({ text: 'Fuel 100%', visible: true })
829
+ \`\`\`
830
+
831
+ Turn on the \`ui-layout\` overlay (\`debug_overlays\`) to see node rects, padding, and margins.
832
+ `,
833
+ "save-and-settings.md": `# Save the game, and remember settings
834
+
835
+ Saves are on in every project (no plugin to add). A save holds only what changed: per loaded
836
+ scene, the fields that differ from the scene file and the entities despawned; entities spawned at
837
+ runtime (prefab instances as the prefab plus overrides); resources defined with \`persist\`; named
838
+ RNG streams; and time. Loading reloads each scene from its current file, so scene edits made after
839
+ a save show up, and the player's changes stay.
840
+
841
+ \`\`\`ts
842
+ import { loadGame, saveGame } from '@aethervtt/shard-save'
843
+
844
+ await saveGame(world, 'slot1', { meta: { label: 'Crash site' } })
845
+ await loadGame(world, 'slot1') // between frames: from a UI click handler, ready(), or a tool
846
+ \`\`\`
847
+
848
+ - What's saved: components that serialize, unless defined with \`save: false\`. Tag an entity
849
+ \`save/NoSave\` to leave it out (and keep it as it is through a load).
850
+ - Resources: \`project.resource('Inventory', { schema, persist: true, init })\`, where \`schema\` is a
851
+ \`defineSchema\` of its fields. Only resources with \`persist\` go in saves.
852
+ - Loot that matches after a load: draw from \`world.resource(GlobalRng).stream('<project>/loot')\`
853
+ (a named stream); saves record every stream's state.
854
+ - A component whose fields change gets \`version\` and \`migrate\`; old saves migrate on load.
855
+ Migrations see only the saved fields, so handle missing ones.
856
+ - Saves go to platform storage: \`.shard/user/saves/<slot>.json\` for \`shard serve\` and
857
+ \`shard mcp\` (tests and \`shard run\` keep them in memory), IndexedDB in the browser.
858
+
859
+ Test fixtures: \`save_game\`, then \`save.read\` gives the JSON (schema
860
+ \`.shard/schemas/save.schema.json\`). Edit a value and load it with \`load_game { json }\`:
861
+
862
+ \`\`\`ts
863
+ const save = await game.saves.read()
864
+ save.scenes['scenes/main.scene.json'].changed.ship = { '<project>/Ship': { fuel: 10 } }
865
+ await game.saves.load(save)
866
+ \`\`\`
867
+
868
+ \`load_game\` returns warnings (\`save/stale-entity\`) for saved changes whose entity is no longer in
869
+ the scene file.
870
+
871
+ ## Settings
872
+
873
+ \`\`\`ts
874
+ export const Settings = project.settings({
875
+ invertY: t.bool(),
876
+ difficulty: t.enum(['normal', 'easy', 'hard']),
877
+ })
878
+ \`\`\`
879
+
880
+ Settings load before Startup: project defaults from \`settings/*.json\`
881
+ (\`{ "<project>/Settings": { "difficulty": "easy" } }\`), then the player's \`settings.json\`. Read them
882
+ like any resource; plain writes persist within half a second. \`setSettings(world, Settings, {...})\`
883
+ validates and applies at once. \`engine/Settings\` is built in: \`volumes\` (bus \u2192 volume), \`quality\`
884
+ (shadow map sizes), \`locale\`, and \`bindings\`, which \`rebindAction(world, '<map>.<action>',
885
+ ['Key:KeyJ'])\` fills in. Agents use \`settings.get\` and \`settings.set\`.
886
+ `,
887
+ "localize.md": `# Localize text
888
+
889
+ String tables are \`locales/<locale>.strings.json\` (schema \`.shard/schemas/strings.schema.json\`),
890
+ one per locale, named by BCP 47 tag:
891
+
892
+ \`\`\`json
893
+ { "hud.fuel": "Fuel: {amount}%", "items.count": { "one": "{n} item", "other": "{n} items" } }
894
+ \`\`\`
895
+
896
+ - Give text a key instead of (or besides) its text: \`"ui/UiText": { "key": "hud.fuel", "params":
897
+ { "amount": 100 } }\`. \`text/Text\` and \`text/ScreenText\` take \`key\` and \`params\` too. The
898
+ translated string shows, and follows locale switches on the next frame. \`text\` shows until the
899
+ key resolves.
900
+ - Update a parameter from a system: \`world.set(label, UiText, { params: { amount: pct } })\`.
901
+ - Code: \`tr(world, 'items.count', { n: 3 })\`. Plural forms (zero, one, two, few, many, other) are
902
+ picked by \`n\` (or \`count\`) with \`Intl.PluralRules\`; numbers format with \`Intl.NumberFormat\`.
903
+ - Switch: \`setLocale(world, 'pt-BR')\`, or \`locale.set\` from a tool. \`pt-BR\` falls back to \`pt\`,
904
+ then \`text/Locale\`'s \`fallback\` (\`en\`). The choice persists in \`engine/Settings\`.
905
+
906
+ Check it: \`shard validate\` reports keys a locale lacks, \`{params}\` that differ between locales,
907
+ and keys scenes and prefabs use that no table defines. At runtime, \`locale.missing\` lists what
908
+ didn't resolve, and \`ui_describe\` shows each node's \`key\` and translated \`text\`.
909
+ `,
910
+ "make-it-navigate.md": `# Make it navigate
911
+
912
+ Add \`"nav"\` to \`plugins\` in \`shard.json\` (\`"nav/grid"\` for grid-only 2D games: no WASM).
913
+
914
+ **3D: a navmesh.** Tag level geometry with \`nav/NavSource\`: its \`physics/Collider\` (or, without one,
915
+ its \`render/Mesh3d\`) and those of children bake. One \`nav/NavMesh\` entity holds the settings:
916
+
917
+ \`\`\`json
918
+ { "name": "navmesh", "components": { "nav/NavMesh": { "agentRadius": 0.4, "agentHeight": 1.8,
919
+ "maxClimb": 0.3, "maxSlope": 45 } } },
920
+ { "name": "ground", "components": { "core/Transform": {},
921
+ "physics/Collider": { "shape": "cuboid", "halfExtents": [20, 0.5, 20] }, "nav/NavSource": {} } },
922
+ { "name": "swamp", "components": { "core/Transform": { "translation": [6, 0, 0] },
923
+ "physics/Collider": { "shape": "cuboid", "halfExtents": [3, 0.5, 3] },
924
+ "nav/NavSource": { "area": 1 } } }
925
+ \`\`\`
926
+
927
+ - Area costs are the \`nav/Areas\` resource: \`"resources": { "nav/Areas": { "1": 4 } }\` makes area 1
928
+ four times as costly (0 excludes it). Slopes steeper than \`maxSlope\` and gaps narrower than the
929
+ agent don't bake.
930
+ - Jumps and ladders: \`nav/OffMeshLink\` on an entity at one end, \`to\` the entity at the other.
931
+ - Bake: \`shard bake nav\` (or the \`nav_bake\` tool) writes the tiles to \`.shard/cache/nav\`; loading
932
+ then skips Recast. Moving a source at runtime rebuilds only the tiles it touches.
933
+
934
+ **2D: a grid.** A \`nav/NavGrid\` with \`source: "tilemap"\` on a \`sprite/Tilemap\` entity makes a cell
935
+ per tile, blocked where the \`layer\` has a tile (or only \`blockingTiles\`). \`source: "colliders"\`
936
+ rasterizes fixed 2D colliders; \`source: "data"\` reads a \`*.navgrid.json\`. \`diagonal\`:
937
+ \`no-corners\`, \`never\`, or \`always\`. Editing tiles with \`setTile\` updates the grid.
938
+
939
+ **Agents.** \`nav/NavAgent\` walks to \`destination\` or follows \`target\` (an entity path), repathing when
940
+ it moves. \`drive: "character"\` writes \`physics/CharacterIntent\` (slopes, steps, collisions), \`"velocity"\`
941
+ the body's \`physics/Velocity\`, \`"transform"\` moves it directly. \`nav/NavAgentState\` has \`status\`
942
+ (idle, moving, arrived, unreachable) and \`remaining\`; systems read \`nav/NavArrived\` and
943
+ \`nav/NavUnreachable\` events. From code: \`findPath(world, from, to, { out })\`, \`nearestPoint\`,
944
+ \`navRaycast\` from \`@aethervtt/shard-nav\`.
945
+
946
+ Check it as data: \`nav_path\` says whether two points connect, \`nav_describe\` lists tiles, skipped
947
+ sources, and each agent's route. In gameplay tests:
948
+
949
+ \`\`\`ts
950
+ expect((await game.nav.path([0, 0, 10], [6, 3, -12])).status).toBe('complete')
951
+ await game.step(600)
952
+ expect(await game.nav.agent('enemy')).toMatchObject({ status: 'arrived' })
953
+ \`\`\`
954
+
955
+ Turn on the \`navmesh\` overlay (\`debug_overlays\`) to see polygons by area, links, and paths.
956
+ `,
957
+ "make-a-noise-graph.md": `# Make a noise graph
958
+
959
+ Terrain heights, biome masks, rock displacement, and cloud textures all start as a noise graph:
960
+ \`assets/noise/<name>.noise.json\`, validated against \`.shard/schemas/noise.schema.json\` (errors point
961
+ into the file). One file gives the CPU (gameplay, headless tests) and the GPU (materials, compute)
962
+ the same function.
963
+
964
+ \`\`\`json
965
+ { "output": "height",
966
+ "nodes": {
967
+ "continents": { "fbm": { "source": "simplex", "octaves": 5, "frequency": 0.8, "seed": 1 } },
968
+ "mountains": { "ridged": { "source": "simplex", "octaves": 6, "frequency": 3.2, "seed": 2 } },
969
+ "mask": { "remap": { "input": "continents", "from": [0.1, 0.4], "to": [0, 1], "clamp": true } },
970
+ "warped": { "warp": { "input": "mountains", "by": "continents", "amount": 0.15 } },
971
+ "height": { "add": ["continents", { "multiply": ["warped", "mask"] }] } } }
972
+ \`\`\`
973
+
974
+ - An input is a node name, an inline node, or a number. Sources (value, perlin, simplex, cellular)
975
+ and fractals (fbm, ridged, billow) take \`seed\` and \`frequency\` (features per unit). Operators:
976
+ add, multiply, min, max, lerp, select, remap, clamp, curve, terrace, abs, power, constant; domain:
977
+ warp, scale, translate. The schema lists every parameter with its range.
978
+ - Layer, don't hand-tune one node: a low-frequency fbm for continents, ridged for mountains, a
979
+ \`remap\` of the continents (clamped to [0, 1]) as a mask so mountains only rise on land, and a
980
+ small \`warp\` so ridges bend. Change one layer's \`seed\` to reroll only that layer.
981
+ - Frequencies are per unit of the domain. For a planet of radius R, continents are around 1/R to 4/R.
982
+ Set \`"extent"\` to the radius and the importer rejects octaves too fine to address that far out.
983
+ - Check it numerically, not by eye: MCP \`noise_stats\` with \`{ "graph": "assets/noise/planet.noise.json",
984
+ "domain": "sphere", "thresholds": [0] }\` says what fraction of the surface is below 0 (under water).
985
+ \`sample_noise\` gives exact values at points; pass \`"graph"\` as JSON to try an edit before saving.
986
+ - Look at it: \`preview_noise\` (or \`preview_asset\` with \`options\`) renders it in grayscale, on a plane or a
987
+ sphere; \`"node": "mask"\` shows one intermediate node on its own.
988
+ - In code: \`sampleNoise\` (near the origin), \`sampleOffset\` (any distance: an f64 origin plus small
989
+ offsets), \`sampleSpherePatch\` / \`sampleGrid2d\`, and \`...Async\` versions on the worker pool.
990
+ - In a material: \`project.material('Rock', { shader: 'project::rock', noise: { detail:
991
+ 'assets/noise/rock.noise.json' } })\` makes \`noise_detail(p: vec3f, seed: u32) -> f32\` available
992
+ to \`import material::rock::noise_detail;\`. Saving the graph re-renders the material.
993
+ `,
994
+ "make-a-generator.md": `# Make a generator
995
+
996
+ Don't place 10 000 rocks: write the rule for one rock and let seeds make the rest. A generator is a
997
+ pure function \`(seed, params) \u2192 output\` (a mesh, a texture, a data value, or entities), cached by the
998
+ hash of its inputs, so the same seed gives the same bytes on every host.
999
+
1000
+ 1. Define it in \`scripts/\` (any module the entry imports):
1001
+
1002
+ \`\`\`ts
1003
+ export const Rock = project.generator('Rock', {
1004
+ params: {
1005
+ radius: t.f32({ default: 1, min: 0.05, unit: 'm' }),
1006
+ roughness: t.f32({ default: 0.3, min: 0, max: 1 }),
1007
+ detail: t.u8({ default: 3, min: 0, max: 6 }),
1008
+ shape: t.handle('NoiseGraph', { description: 'Displacement noise.' }),
1009
+ },
1010
+ output: 'mesh',
1011
+ run(ctx, p) {
1012
+ const mesh = ctx.mesh.icosphere(p.detail)
1013
+ const shape = ctx.load<NoiseGraph>(p.shape) // only handles in params; editing it regenerates
1014
+ const n = new Float32Array(mesh.positions.length / 3)
1015
+ ctx.noise.sample(shape, ctx.seed, mesh.positions, n)
1016
+ for (let i = 0; i < n.length; i++) {
1017
+ const s = p.radius * (1 + p.roughness * n[i]!)
1018
+ for (let k = 0; k < 3; k++) mesh.positions[i * 3 + k]! *= s
1019
+ }
1020
+ return ctx.mesh.finish(mesh, { normals: true, tangents: true, lods: [0.5, 0.2] })
1021
+ },
1022
+ })
1023
+ \`\`\`
1024
+
1025
+ \`output\` is \`'mesh'\`, \`'texture'\` (\`{ width, height, mips: [rgba8] }\`), \`'data'\` or a data type
1026
+ (\`project.dataAsset\`), or \`'entities'\` (a prefab-shaped entity \`{ name, components, children }\`,
1027
+ or a list of them). A generator can use another: \`await ctx.generate(Planet, params, ctx.childSeed(i))\`
1028
+ returns a ref to put in its output; each is cached on its own.
1029
+ 2. Keep it pure. Randomness is \`ctx.rng\` (seeded from the seed and the generator's name), per-part
1030
+ seeds \`ctx.childSeed(label)\`. \`Math.random\`, \`Date.now\`, and \`performance.now\` throw
1031
+ \`procgen/nondeterministic\` (and \`shard check\` flags them). Prefer \`+ * sqrt\` over trig for
1032
+ geometry that must match across browsers.
1033
+ 3. Look at it: MCP \`preview_generator { "generator": "<project>/Rock", "seeds": "1-9", "params": { "roughness": 0.6 } }\`
1034
+ (or \`shard gen scripts:Rock --seeds 1-9 --out sheet.png\`) renders a labelled contact sheet. Change
1035
+ one param, preview again, compare. \`run_generator\` reports vertex and triangle counts, bounds,
1036
+ the cache key, and whether it was a cache hit.
1037
+ 4. Place it:
1038
+ - a file \`generators/boulder.gen.json\` (schema \`.shard/schemas/gen.schema.json\`):
1039
+ \`{ "generator": "<project>/Rock", "seed": 3, "params": { "radius": 2, "shape": { "path": "assets/noise/rock.noise.json" } } }\`,
1040
+ then \`"mesh": { "path": "generators/boulder.gen.json" }\` in a prefab (\`#LOD1\` for a level of detail)
1041
+ - a ref: \`"mesh": { "path": "procedural:<project>/Rock?seed=3&radius=2" }\`
1042
+ - entities: \`"procgen/GeneratorInstance": { "generator": { "path": "<project>/StarSystem" }, "seed": 7 }\`
1043
+ spawns the fragment as children; patching \`seed\` regenerates and keeps the ids of unchanged paths
1044
+ - at runtime: \`const mesh = await generate(world, Rock, { radius: 2 }, seed)\`
1045
+ 5. \`shard validate\` checks every \`*.gen.json\` against its generator (\`procgen/bad-params\` with a pointer).
1046
+ Editing the generator's code (or a helper it imports) regenerates its outputs on hot reload; the
1047
+ reload report lists them under \`procgen.regenerated\`.
1048
+ `,
1049
+ "make-particles.md": `# Make a particle effect
1050
+
1051
+ Add \`"particles"\` to \`plugins\` in \`shard.json\`. An effect is \`assets/fx/<name>.particles.json\`,
1052
+ validated against \`.shard/schemas/particle-effect.schema.json\` (errors point into the file):
1053
+
1054
+ \`\`\`json
1055
+ { "emitters": [{ "name": "exhaust", "capacity": 20000, "spawn": { "rate": 4000 },
1056
+ "shape": { "type": "cone", "angle": 8, "radius": 0.2 },
1057
+ "init": { "lifetime": [0.3, 0.6], "speed": [18, 25], "size": [0.2, 0.35], "color": "#9ad4ff" },
1058
+ "update": [{ "module": "drag", "coefficient": 1.5 }, { "module": "curl-noise", "strength": 2 },
1059
+ { "module": "color-over-life", "gradient": [[0, "#e9f6ff", 1], [1, "#1a3a7a", 0]] }],
1060
+ "render": { "blend": "additive", "emissive": 4000 } }] }
1061
+ \`\`\`
1062
+
1063
+ - Values: a number or \`[min, max]\`; curves \`[[t, v], \u2026]\`; gradients \`[[t, color, alpha], \u2026]\`.
1064
+ - Modules: gravity, drag, velocity-over-life, curl-noise, attractor, rotation, color-over-life,
1065
+ size-over-life, collision (bounces off what the camera sees).
1066
+ - Use it: \`"particles/ParticleSystem": { "effect": { "path": "assets/fx/exhaust.particles.json" } }\`;
1067
+ \`space: "local"\` keeps particles attached. Drive it from gameplay with
1068
+ \`particles/ParticleEmitterOverrides\` (\`spawnScale\`). Saving the file updates the running effect.
1069
+ - Look: MCP \`preview_asset\` on the file shows it after one second; \`render.describe\` \u2192
1070
+ \`particles\` gives alive counts.
1071
+ `,
1072
+ "large-worlds.md": `# Build a large world: grids and the floating origin
1073
+
1074
+ Positions are f32. Past about 10 km from the origin they lose millimetres, and past 1 000 km they
1075
+ visibly jitter. For anything bigger (a star system, a planet you can land on), put entities in a
1076
+ \`transform/Grid\` and mark the camera \`transform/FloatingOrigin\`. A small level doesn't need any of this.
1077
+
1078
+ A position in a grid is \`GridCell.cell \xD7 Grid.cellSize + Transform.translation\`. The cell is an exact
1079
+ integer and the translation stays under half a cell, so everything near the camera is precise.
1080
+ \`GlobalTransform\` is relative to the origin's cell, so it stays small too.
1081
+
1082
+ \`\`\`json
1083
+ { "name": "system", "components": { "transform/Grid": { "cellSize": 2000 } }, "children": [
1084
+ { "name": "planet", "components": {
1085
+ "transform/Grid": { "cellSize": 2000 },
1086
+ "transform/GridCell": { "cell": [75000000, 0, 0] },
1087
+ "core/Transform": { "rotationEuler": [0, 20, 0] } } },
1088
+ { "name": "ship", "components": {
1089
+ "transform/GridCell": { "cell": [74996000, 0, 0] },
1090
+ "core/Transform": { "translation": [120, 0, -40] } },
1091
+ "children": [ { "name": "camera", "components": {
1092
+ "render/Camera3d": {}, "transform/FloatingOrigin": {} } } ] } ] }
1093
+ \`\`\`
1094
+
1095
+ | Grid | Suggested cell | Reach (\xB12\xB3\xB9 cells) | f32 offset precision |
1096
+ |---|---|---|---|
1097
+ | Galaxy | 10\xB9\xB2 m | 2\xD710\xB2\xB9 m | ~60 km, fine for stars |
1098
+ | Star system | 2 000 m | 4\xD710\xB9\xB2 m (~30 AU) | ~0.1 mm |
1099
+ | Planet | 2 000 m | far beyond any planet | ~0.1 mm |
1100
+
1101
+ - Only direct children of a grid carry \`GridCell\`. Deeper descendants use their parent's transform as
1102
+ usual. A nested grid (a planet) is a \`Grid\` with a \`GridCell\` in its parent grid; spin it with its
1103
+ own \`Transform\` rotation and everything on its surface turns with it.
1104
+ - Moving things: write the translation as usual. \`transform/recenter\` moves an entity into the next
1105
+ cell once it goes \`cellSize / 2 + hysteresis\` past the centre. Don't write a far position into
1106
+ \`translation\`: \`shard validate\` reports \`transform/translation-outside-cell\`.
1107
+ - One \`FloatingOrigin\` per world (\`transform/multiple-origins\`), usually on the camera. When it
1108
+ changes cells, \`transform/OriginShift\` fires with the offset: code that caches \`GlobalTransform\`
1109
+ positions across frames adds \`offset\` to them.
1110
+ - From tools: \`patch_entity\` with \`{ "position64": [3.8e8, 0, 0], "grid": "system" }\` places an entity
1111
+ exactly and splits the cell for you. \`get_entity\` returns \`worldPosition64\` (relative to the origin
1112
+ cell), and \`app.describe\` lists the grids and where the origin is.
1113
+ - In code (cold paths, f64): \`worldPosition64(world, e, out, frame?)\`, \`distance64(world, a, b)\`,
1114
+ \`placeInGrid(world, e, grid, position)\`, and \`reparentToGrid(world, ship, planetGrid)\` to move a
1115
+ ship into a rotating planet grid without a jump.
1116
+ - Physics runs in the origin's frame, so bodies near the camera are precise; saves store cells, so a
1117
+ save 10\xB9\xB2 m out loads to the millimetre.
1118
+ `,
1119
+ "make-a-planet.md": `# Make a planet you can land on
1120
+
1121
+ A planet is an entity with \`terrain/Planet\` (it brings \`transform/Grid\`) and the \`terrain\` plugin in
1122
+ \`shard.json\` (with \`render/forward\` to draw it and \`physics3d\` to stand on it). Heights come from a
1123
+ noise graph (make-a-noise-graph.md); chunks are generated on the GPU as the camera moves, from six at
1124
+ orbit down to 0.4 m vertex spacing on the ground. Put the camera in the planet's grid with
1125
+ \`transform/FloatingOrigin\` (large-worlds.md).
1126
+
1127
+ \`\`\`json
1128
+ { "name": "planet", "components": {
1129
+ "transform/Grid": { "cellSize": 2000 },
1130
+ "terrain/Planet": { "radius": 600000, "heightScale": 3000, "seed": 7,
1131
+ "height": { "path": "assets/noise/planet.noise.json" },
1132
+ "climate": { "path": "assets/noise/climate.noise.json" },
1133
+ "biomes": { "path": "assets/biomes/planet.biomes.json" }, "ocean": true, "seaLevel": 0 },
1134
+ "physics/GravitySource": { "strength": 9.81, "radius": 600000 } } }
1135
+ \`\`\`
1136
+
1137
+ - Height: the graph's output (about \u22121 to 1) \xD7 \`heightScale\` metres above \`radius\`. The graph is
1138
+ sampled at points on the sphere in metres, so frequency 1e-5 is 100 km features. Keep \`radius\` under
1139
+ 50 000 km (\`terrain/radius-too-large\`).
1140
+ - Climate: a graph with \`temperature\` and \`moisture\` nodes (\u22121 to 1). Latitude and altitude cool it
1141
+ (\`latitudeBias\`, \`snowLine\` in the BiomeSet).
1142
+ - Biomes: \`*.biome.json\` files (ranges of temperature, moisture, height, slope; a tint; up to four
1143
+ texture layers by slope) listed in a \`*.biomes.json\` with \`albedo\`, \`normal\`, and \`orm\`
1144
+ \`*.texarray.json\` texture arrays (\`{ "layers": ["grass.png", "rock.png"], "size": 1024 }\`).
1145
+ - Standing on it: characters, dynamic bodies, and \`terrain/TerrainAnchor\` entities get collider chunks
1146
+ within \`colliderRadius\`, built from the same CPU noise as \`planetHeightAt(world, planet, dir)\`, and
1147
+ those chunks render their own vertices. Use \`CharacterController\` with \`up: "gravity"\`.
1148
+ - Walking agents: add \`terrain/PlanetNav\` to the planet; a navmesh follows the \`NavAgent\`s.
1149
+ - Check it: MCP \`terrain_map\` (\`{ "mode": "biomes" }\`) shows continents, sea, and poles in one image;
1150
+ \`sample_terrain\` gives height, water depth, slope, and biome at \`[lat, lon]\` points;
1151
+ \`describe_terrain\` lists chunks per depth, generation, and colliders. Overlays \`terrain-lod\`,
1152
+ \`terrain-biomes\`, and \`terrain-colliders\` show them in a screenshot.
1153
+ - Edit the height graph while \`shard dev\` runs: visible chunks regenerate together, colliders too.
1154
+ \`terrain/Budget\` caps chunks per frame and GPU time.
1155
+ - Frame time: \`vertexPixels\` (4) stops chunks splitting once vertices are that many pixels apart
1156
+ (normal tiles keep the finer relief in the shading), and \`terrain/Budget.triangles\` (2M) coarsens
1157
+ a planet a little at a time when it draws more, so frame time holds at any resolution.
1158
+ \`describe_terrain\` shows \`detail.lodBias\` above 1 while it's coarsening.
1159
+ - Give it air: \`"render/Atmosphere": {}\` on the planet entity takes the planet's radius, draws
1160
+ the sky from the ground to orbit, and hazes distant terrain (tune-an-atmosphere.md).
1161
+ `,
1162
+ "tune-an-atmosphere.md": `# Tune an alien sky
1163
+
1164
+ \`render/Atmosphere\` on a planet entity (the \`terrain/Planet\`, or any entity: it's centered on
1165
+ its origin) gives it a sky you can see from the surface, from a ship, and from orbit as a glowing
1166
+ limb. It also hazes distant terrain and dims and reddens the sun near the horizon, for lighting too.
1167
+ \`bottomRadius: 0\` takes the Planet's radius. Units are physical: metres and 1/m.
1168
+
1169
+ \`\`\`json
1170
+ "render/Atmosphere": { "thickness": 60000, "rayleighScattering": [5.8e-6, 13.6e-6, 33.1e-6] }
1171
+ \`\`\`
1172
+
1173
+ Presets (\`AtmospherePresets\` in code; copy the values into a scene): \`earth\` (the defaults),
1174
+ \`mars\`, \`thin\`, \`thick-haze\`, \`alien-violet\`, \`gas-giant\`.
1175
+
1176
+ Which knob does what:
1177
+
1178
+ - **Sky color and sunset color: \`rayleighScattering\`.** The sky takes the color that scatters most;
1179
+ sunsets get what's left after the long path. Blue above red gives a blue sky with orange sunsets.
1180
+ Reverse it for a red sky with blue sunsets; raise red and blue for violet. Scale all three
1181
+ together to thicken or thin the air.
1182
+ - **Haze and the glow around the sun: \`mieScattering\`** (4e-6 very clear, 1e-4 hazy) with
1183
+ \`mieScale\` (how high the haze reaches) and \`mieG\` (how tight the halo is). \`mieAbsorption\` makes
1184
+ the haze dirtier and darker. \`mieGOffset\` gives different colors different halos: Mars' blue
1185
+ sunset is blue scattered more forward, \`[-0.15, 0, 0.2]\`.
1186
+ - **Twilight and tinted air: \`absorption\`**, a layer at \`absorptionCenter\` \`absorptionWidth\` wide.
1187
+ Earth's ozone keeps twilight blue; at center 0 and a wide layer it's dust (Mars' butterscotch) or
1188
+ methane (Neptune's blue: absorb red).
1189
+ - **How far you see: all of the above.** Distant terrain turns the sky's color; 20 km on Earth is
1190
+ noticeably blue, a few km in \`thick-haze\`.
1191
+ - **Ground below the horizon:** \`groundAlbedo\`, where no terrain is drawn (from high up, or before
1192
+ chunks load).
1193
+ - **Gas giants:** \`bottomRadius\` is the cloud top and \`deckDepth\` the haze below it that thickens to
1194
+ opaque cloud as a ship dives.
1195
+
1196
+ Check it without screenshots: MCP \`sample_atmosphere\` (\`{ "direction": [0, 1, 0] }\`) gives sky
1197
+ radiance and transmittance from the camera; with \`position\` 40 km up the sky should be near black.
1198
+ \`render.describe\`'s \`atmosphere\` section lists each camera's primary atmosphere, the sunlight
1199
+ reaching the camera (\`illuminanceAtCamera\`), and GPU time. In code, \`sunTransmittanceAt(world,
1200
+ planet, position)\` is how much sunlight gets through, for gameplay (solar panels, heat).
1201
+
1202
+ - The sun's \`DirectionalLight.illuminance\` is above the air (direct-sun 100 000). Its
1203
+ \`angularDiameter\` (0.53\xB0) sizes the disk; the two brightest lights both draw one.
1204
+ - \`render/AtmosphereSettings\` on a camera: \`aerialPerspective: false\` for only the sky; the froxel
1205
+ range \`maxDistance\` (32 km) beyond which haze is marched per pixel.
1206
+ - \`render/Fog\` is for flat scenes: inside an atmosphere it's ignored (\`render/fog-with-atmosphere\`)
1207
+ unless \`mode: "add"\`, for ground fog on top.
1208
+ - \`render/ProceduralSky\` is an Earth atmosphere pinned 10 m under the camera: fine for a level, but
1209
+ you can't fly out of it.
1210
+ `,
1211
+ "inspect-a-scene.md": `# Find what's wrong in a scene
1212
+
1213
+ When a screenshot looks wrong, connect the pixels to entities:
1214
+
1215
+ 1. \`screenshot\` with \`"overlays": ["bounds", "labels"]\`: every mesh gets its outline and its scene
1216
+ path. Add \`"filter": "ship/"\` to label one subtree. Other overlays: lights, cameras, cascades,
1217
+ normals, axes.
1218
+ 2. \`pick\` a pixel that looks wrong (same coordinates as the screenshot): its path, position, and
1219
+ normal. Or \`raycast\` from a point, e.g. straight down to find the ground.
1220
+ 3. \`get_entity\` on that path, \`patch_entity\` the fix, and screenshot again with the same overlays.
1221
+
1222
+ In your own systems, draw with gizmos (they last one frame, or \`duration\` seconds):
1223
+
1224
+ \`\`\`ts
1225
+ const g = world.resource(Gizmos)
1226
+ g.line(from, to, [1, 0.2, 0.2, 1])
1227
+ g.arrow(position, target, [1, 1, 0, 1], { width: 2 })
1228
+ g.sphere(center, radius, [0, 1, 1, 1], { depthTest: false })
1229
+ g.label(position, 'target', [1, 1, 1, 1])
1230
+ \`\`\`
1231
+
1232
+ \`list_gizmos\` shows what was drawn as data. \`pick(world, camera, x, y)\` and
1233
+ \`raycast(world, origin, direction)\` from \`@aethervtt/shard-render\` do the same in code.
1234
+ `,
1235
+ "debug-with-screenshots.md": `# Look at the game
1236
+
1237
+ - \`shard screenshot <scene> --out shot.png --frames 60\` renders headless (no window needed).
1238
+ - With the MCP server: \`screenshot\` returns the image; \`step\` advances frames; \`press\`/\`hold\`
1239
+ drive input; \`query_entities\` / \`get_entity\` read state; \`recent_errors\` shows what failed.
1240
+ `
1241
+ };
1242
+ function agentsBlock(manifest) {
1243
+ return `${GENERATED_START}
1244
+ # ${manifest.name}
1245
+
1246
+ A [Shard](https://github.com/) project. The engine is data-first: scenes are JSON validated against
1247
+ component schemas, game code is ECS (components, systems) in \`scripts/\`.
1248
+
1249
+ ## Commands
1250
+
1251
+ \`\`\`sh
1252
+ shard validate --json # manifest, assets, scenes: every error with a path
1253
+ shard import --json # import new and changed asset files
1254
+ shard mv <from> <to> # move an asset and rewrite references to it
1255
+ shard run --frames 600 # headless run
1256
+ shard screenshot ${manifest.startScene} --out shot.png
1257
+ shard test --json # gameplay tests in tests/
1258
+ shard check --json # type-check scripts: file, line, column
1259
+ shard gen <generator> --seeds 1-9 --out sheet.png # preview a generator's outputs
1260
+ shard dev # play it in a browser; saves hot reload in place
1261
+ shard docs # regenerate this block, .agents/, and .shard/schemas/
1262
+ shard mcp # MCP server for this project (see .mcp.json)
1263
+ \`\`\`
1264
+
1265
+ ## Where things are
1266
+
1267
+ - \`shard.json\`: manifest (start scene: \`${manifest.startScene}\`, seed ${manifest.seed}, plugins: ${manifest.plugins.join(", ")})
1268
+ - \`scripts/main.ts\`: the project plugin; project types are named \`${manifest.name}/<Name>\`
1269
+ - \`scenes/\`: scene files; \`tests/\`: gameplay tests; \`shaders/\`: \`project::\` shader modules
1270
+ - \`generators/\`: \`*.gen.json\` generator outputs; \`.agents/generators.md\`: every generator and its params
1271
+ - \`${manifest.assetRoots.join("/`, `")}/\`: asset files, each with a \`.meta\` (guid, import settings)
1272
+ - \`.agents/components.md\`: every component and field; \`.agents/assets.md\`: importers and
1273
+ project data types;
1274
+ \`.agents/errors.md\`: error codes
1275
+ - \`.agents/skills/\`: recipes for common tasks
1276
+
1277
+ Engine plugins available: ${Object.entries(BUILTIN_PLUGINS).map(([n, d]) => `\`${n}\` (${d})`).join("; ")}.
1278
+ ${GENERATED_END}`;
1279
+ }
1280
+ function mergeAgentsMd(existing, manifest) {
1281
+ const block = agentsBlock(manifest);
1282
+ if (!existing)
1283
+ return `${block}
1284
+
1285
+ ## Notes
1286
+
1287
+ Project-specific notes for agents go here; \`shard docs\` keeps them.
1288
+ `;
1289
+ const start = existing.indexOf(GENERATED_START);
1290
+ const end = existing.indexOf(GENERATED_END);
1291
+ if (start === -1 || end === -1) return `${block}
1292
+
1293
+ ${existing}`;
1294
+ return `${existing.slice(0, start)}${block}${existing.slice(end + GENERATED_END.length)}`;
1295
+ }
1296
+ function generateDocs(manifest, errors) {
1297
+ const files = {
1298
+ ".agents/components.md": renderComponentCatalog(),
1299
+ ".agents/errors.md": renderErrorCatalog(errors),
1300
+ ".agents/generators.md": renderGeneratorCatalog(),
1301
+ ".agents/assets.md": renderAssetCatalog(
1302
+ allImporters(),
1303
+ allDataTypes().filter((d) => d.name.startsWith(`${manifest.name}/`))
1304
+ ),
1305
+ ".shard/schemas/shard.schema.json": `${JSON.stringify(manifestJsonSchema(), null, 2)}
1306
+ `,
1307
+ ".shard/schemas/scene.schema.json": `${JSON.stringify(sceneJsonSchema(), null, 2)}
1308
+ `,
1309
+ ".shard/schemas/prefab.schema.json": `${JSON.stringify(prefabJsonSchema(), null, 2)}
1310
+ `,
1311
+ ".shard/schemas/save.schema.json": `${JSON.stringify(saveJsonSchema(), null, 2)}
1312
+ `
1313
+ };
1314
+ for (const [name, content] of Object.entries(SKILLS))
1315
+ files[`.agents/skills/${name}`] = content.replaceAll("<project>", manifest.name);
1316
+ for (const [file, schema] of allAssetSchemas()) {
1317
+ files[`.shard/schemas/${file}`] = `${JSON.stringify(schema(), null, 2)}
1318
+ `;
1319
+ }
1320
+ for (const def of allComponents()) {
1321
+ if (def.name.startsWith("test/")) continue;
1322
+ files[`.shard/schemas/components/${def.name.replace("/", ".")}.json`] = `${JSON.stringify(def.jsonSchema(), null, 2)}
1323
+ `;
1324
+ }
1325
+ return files;
1326
+ }
1327
+ export {
1328
+ GENERATED_END,
1329
+ GENERATED_START,
1330
+ generateDocs,
1331
+ mergeAgentsMd,
1332
+ renderAssetCatalog,
1333
+ renderComponentCatalog,
1334
+ renderErrorCatalog,
1335
+ renderGeneratorCatalog
1336
+ };