@jgengine/shell 0.22.0 → 0.23.0

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/GamePlayerShell.js +18 -3
  3. package/dist/GameUiPreview.js +2 -1
  4. package/dist/Shell3dPresentation.js +13 -8
  5. package/dist/ShellChrome.d.ts +1 -1
  6. package/dist/ShellChrome.js +4 -2
  7. package/dist/ShellHudPresentation.js +2 -1
  8. package/dist/ShellNotices.d.ts +26 -0
  9. package/dist/ShellNotices.js +51 -0
  10. package/dist/audio/AudioComponents.d.ts +3 -1
  11. package/dist/audio/AudioComponents.js +5 -2
  12. package/dist/audio/listenerAnchor.d.ts +15 -0
  13. package/dist/audio/listenerAnchor.js +17 -0
  14. package/dist/audio/playableAudio.d.ts +9 -0
  15. package/dist/audio/playableAudio.js +11 -0
  16. package/dist/commandSink.d.ts +1 -1
  17. package/dist/commandSink.js +8 -3
  18. package/dist/defineGame.js +3 -1
  19. package/dist/devtools/DevtoolsOverlay.js +1 -0
  20. package/dist/devtools/agentBridge.d.ts +21 -0
  21. package/dist/devtools/agentBridge.js +95 -3
  22. package/dist/devtools/devtoolsReports.d.ts +32 -0
  23. package/dist/devtools/devtoolsReports.js +26 -0
  24. package/dist/devtools/simStepper.d.ts +21 -0
  25. package/dist/devtools/simStepper.js +37 -0
  26. package/dist/drivers/FrameDriver.js +26 -14
  27. package/dist/drivers/HudOnlyDriver.js +16 -5
  28. package/dist/environment/EnvironmentScene.js +19 -7
  29. package/dist/environment/RoadJunctions.d.ts +3 -1
  30. package/dist/environment/RoadJunctions.js +9 -2
  31. package/dist/environment/RoadRibbons.d.ts +3 -1
  32. package/dist/environment/RoadRibbons.js +16 -3
  33. package/dist/environment/terrainGroundColor.js +3 -3
  34. package/dist/materialOverride.js +4 -2
  35. package/dist/playSurfaceFocus.d.ts +16 -0
  36. package/dist/playSurfaceFocus.js +42 -0
  37. package/dist/postfx/PostProcessing.d.ts +3 -1
  38. package/dist/postfx/PostProcessing.js +14 -6
  39. package/dist/registry.d.ts +5 -1
  40. package/dist/render/CascadedShadows.d.ts +2 -0
  41. package/dist/render/CascadedShadows.js +4 -0
  42. package/dist/render/EntityPreview.js +1 -1
  43. package/dist/render/EnvironmentLighting.d.ts +26 -7
  44. package/dist/render/EnvironmentLighting.js +40 -27
  45. package/dist/render/SceneLighting.js +2 -2
  46. package/dist/render/modelLoad.d.ts +7 -0
  47. package/dist/render/modelLoad.js +13 -0
  48. package/dist/render/modelRender.d.ts +1 -0
  49. package/dist/render/modelRender.js +3 -0
  50. package/dist/render/modelSurface.d.ts +39 -0
  51. package/dist/render/modelSurface.js +206 -0
  52. package/dist/render/useModelInstance.d.ts +1 -1
  53. package/dist/render/useModelInstance.js +2 -2
  54. package/dist/scatter/scatterModels.js +3 -1
  55. package/dist/scene/AuthoredScene.d.ts +11 -3
  56. package/dist/scene/AuthoredScene.js +17 -9
  57. package/dist/scene/CityRenderer.js +86 -24
  58. package/dist/scene/cityJunctionSurface.d.ts +7 -0
  59. package/dist/scene/cityJunctionSurface.js +34 -0
  60. package/dist/settings/settingsController.js +1 -1
  61. package/dist/terrain/index.d.ts +4 -0
  62. package/dist/terrain/index.js +4 -0
  63. package/dist/terrain/surfaceRibbonMaterial.d.ts +62 -0
  64. package/dist/terrain/surfaceRibbonMaterial.js +227 -0
  65. package/dist/terrain/terrainDetailMaterial.d.ts +33 -10
  66. package/dist/terrain/terrainDetailMaterial.js +149 -63
  67. package/dist/terrain/terrainPaintColor.d.ts +12 -3
  68. package/dist/terrain/terrainPaintColor.js +46 -1
  69. package/dist/terrain/terrainQuality.d.ts +5 -0
  70. package/dist/terrain/terrainQuality.js +7 -0
  71. package/dist/terrain/terrainSurfaceShader.d.ts +16 -0
  72. package/dist/terrain/terrainSurfaceShader.js +266 -0
  73. package/dist/terrain/terrainSurfaces.d.ts +59 -0
  74. package/dist/terrain/terrainSurfaces.js +199 -0
  75. package/dist/world/StaticObjectModels.d.ts +1 -1
  76. package/dist/world/StaticObjectModels.js +1 -1
  77. package/dist/world/WorldScene.js +1 -1
  78. package/dist/world/staticObjectBatches.d.ts +5 -1
  79. package/dist/world/staticObjectBatches.js +9 -5
  80. package/dist/world/worldBarSamples.d.ts +13 -1
  81. package/dist/world/worldBarSamples.js +40 -8
  82. package/package.json +5 -5
  83. package/skills/jgengine/api.md +18 -18
  84. package/skills/jgengine-ui/SKILL.md +4 -0
  85. package/skills/jgengine-ui/api.md +233 -37
  86. package/skills/jgengine-ui/capabilities.md +52 -0
  87. package/skills/jgengine-ui/reference-primitives.md +4 -4
  88. package/skills/jgengine-world/api.md +27 -14
  89. package/skills/jgengine-world/capabilities.md +4 -0
  90. package/skills/jgengine-world/reference.md +23 -8
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { SceneObject } from "@jgengine/core/scene/objectStore";
3
3
  import { type StaticObjectBatch } from "./staticObjectBatches.js";
4
- /** Render compatible authored props as shared model draws, preserving unsupported models. @internal */
4
+ /** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
5
5
  export declare function StaticObjectModels({ batch, single }: {
6
6
  batch: StaticObjectBatch;
7
7
  single: (object: SceneObject) => ReactNode;
@@ -69,7 +69,7 @@ function LoadedStaticObjects({ batch, single }) {
69
69
  return _jsx(_Fragment, { children: batch.objects.map(single) });
70
70
  return _jsxs(_Fragment, { children: [harvested.sources.map((source, index) => objects.length === 0 ? null : _jsx(ObjectSource, { source: source, objects: objects, batch: batch }, index)), excluded.map((id) => { const live = ctx.scene.object.get(id); return live === null ? null : single(live); })] });
71
71
  }
72
- /** Render compatible authored props as shared model draws, preserving unsupported models. @internal */
72
+ /** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
73
73
  export function StaticObjectModels({ batch, single }) {
74
74
  return _jsx(IsolatedModelPart, { model: batch.model, seam: "object", children: _jsx(LoadedStaticObjects, { batch: batch, single: single }) });
75
75
  }
@@ -175,7 +175,7 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
175
175
  }
176
176
  return [{ object, model, custom, style, authored: authoredIds.has(instanceId), catalog: ctx.scene.object.catalog(instanceId) }];
177
177
  });
178
- const grouped = groupStaticObjects(candidates.map((candidate) => ({ ...candidate, custom: candidate.custom !== undefined && candidate.custom !== null })), 24, batchCache.current);
178
+ const grouped = groupStaticObjects(candidates.map((candidate) => ({ ...candidate, custom: candidate.custom !== undefined && candidate.custom !== null })), undefined, batchCache.current);
179
179
  batchCache.current = new Map(grouped.batches.map((batch) => [batch.key, batch]));
180
180
  const candidatesById = new Map(candidates.map((candidate) => [candidate.object.instanceId, candidate]));
181
181
  const renderSingle = (object) => {
@@ -24,7 +24,11 @@ export declare function canBatchStaticObject(candidate: StaticObjectCandidate):
24
24
  export declare function isCompatibleStaticObject(object: SceneObject, model: ModelConfig | undefined, catalog: GameContextObjectEntry | null): boolean;
25
25
  /** Stable exclusion snapshot; only compatibility transitions allocate. @internal */
26
26
  export declare function incompatibleStaticObjectIds(batch: StaticObjectBatch, get: (id: string) => SceneObject | null, catalog: (id: string) => GameContextObjectEntry | null, previous: readonly string[], fallbackObjects?: Map<string, SceneObject>): readonly string[];
27
- /** Groups compatible authored props above eight placements per spatial cell. @internal */
27
+ /** Batch cell edge in metres: one instanced draw per model per cell, culled as a unit. @internal */
28
+ export declare const STATIC_BATCH_CHUNK = 64;
29
+ /** Smallest same-model group worth an instanced draw; below this each prop draws on its own. @internal */
30
+ export declare const STATIC_BATCH_MIN = 3;
31
+ /** Groups compatible authored or `scenery` props, at least {@link STATIC_BATCH_MIN} per spatial cell. @internal */
28
32
  export declare function groupStaticObjects(candidates: readonly StaticObjectCandidate[], chunkSize?: number, previous?: ReadonlyMap<string, StaticObjectBatch>): {
29
33
  batches: StaticObjectBatch[];
30
34
  singles: StaticObjectCandidate[];
@@ -3,7 +3,7 @@ import { POINTER_OBJECT_INSTANCES_KEY } from "../pointer/pointerService.js";
3
3
  /** The same compatibility gate is used when live placements change after grouping. @internal */
4
4
  export function canBatchStaticObject(candidate) {
5
5
  const { object, model, catalog } = candidate;
6
- return candidate.authored && !candidate.custom && candidate.style === undefined &&
6
+ return (candidate.authored || catalog?.scenery === true) && !candidate.custom && candidate.style === undefined &&
7
7
  isCompatibleStaticObject(object, model, catalog);
8
8
  }
9
9
  /** Checks a live placement without constructing a grouping candidate. @internal */
@@ -15,7 +15,7 @@ export function isCompatibleStaticObject(object, model, catalog) {
15
15
  (catalog?.breakable === undefined || catalog.breakable === false) && catalog?.slotInventory === undefined &&
16
16
  (animation === undefined || animation === "auto" || animation === "none") && model.ik === undefined &&
17
17
  (model.scale ?? 1) >= 0 && (model.parts?.length ?? 0) === 0 && (model.attachments?.length ?? 0) === 0 &&
18
- model.partMotion === undefined && model.material?.maps === undefined && (model.materialAssignments?.length ?? 0) === 0 &&
18
+ model.partMotion === undefined && model.surfaceDetail !== false && model.material?.maps === undefined && (model.materialAssignments?.length ?? 0) === 0 &&
19
19
  (model.hiddenNodes === undefined || (Array.isArray(model.hiddenNodes) && model.hiddenNodes.length === 0));
20
20
  }
21
21
  /** Stable exclusion snapshot; only compatibility transitions allocate. @internal */
@@ -49,8 +49,12 @@ export function incompatibleStaticObjectIds(batch, get, catalog, previous, fallb
49
49
  return live !== null && !compatible(live) ? [live.instanceId] : [];
50
50
  });
51
51
  }
52
- /** Groups compatible authored props above eight placements per spatial cell. @internal */
53
- export function groupStaticObjects(candidates, chunkSize = 24, previous) {
52
+ /** Batch cell edge in metres: one instanced draw per model per cell, culled as a unit. @internal */
53
+ export const STATIC_BATCH_CHUNK = 64;
54
+ /** Smallest same-model group worth an instanced draw; below this each prop draws on its own. @internal */
55
+ export const STATIC_BATCH_MIN = 3;
56
+ /** Groups compatible authored or `scenery` props, at least {@link STATIC_BATCH_MIN} per spatial cell. @internal */
57
+ export function groupStaticObjects(candidates, chunkSize = STATIC_BATCH_CHUNK, previous) {
54
58
  const groups = new Map();
55
59
  const singles = [];
56
60
  for (const candidate of candidates) {
@@ -70,7 +74,7 @@ export function groupStaticObjects(candidates, chunkSize = 24, previous) {
70
74
  }
71
75
  const batches = [];
72
76
  for (const { batch, candidates: members } of groups.values()) {
73
- if (batch.objects.length > 8) {
77
+ if (batch.objects.length >= STATIC_BATCH_MIN) {
74
78
  const cached = previous?.get(batch.key);
75
79
  batches.push(cached !== undefined && cached.objects.length === batch.objects.length &&
76
80
  cached.objects.every((object, index) => object.instanceId === batch.objects[index].instanceId) ? cached : batch);
@@ -6,6 +6,8 @@ export interface WorldBarSample {
6
6
  x: number;
7
7
  y: number;
8
8
  percent: number;
9
+ /** Screen pixels per world metre at the anchor, so painted bars shrink with distance and zoom. */
10
+ pxPerMeter?: number;
9
11
  }
10
12
  export interface Projectable {
11
13
  set(x: number, y: number, z: number): this;
@@ -63,9 +65,19 @@ export declare function collectNameplateSamples(ctx: GameContext, statId: string
63
65
  width: number;
64
66
  height: number;
65
67
  }, into: NameplateSample[], project: Projectable, maxDistance?: number, occlude?: boolean, options?: WorldOverlaySampleOptions): number;
68
+ /**
69
+ * Painted bar size for a sample: about {@link WORLD_BAR_METERS} wide at the anchor's on-screen
70
+ * scale, clamped between a still-readable minimum and the configured maximum, with height in
71
+ * proportion. Samples without a scale (caller-painted screen samples) keep the maximum.
72
+ * @internal
73
+ */
74
+ export declare function worldBarSize(pxPerMeter: number | undefined, maxWidthPx?: number, maxHeightPx?: number): {
75
+ width: number;
76
+ height: number;
77
+ };
66
78
  /** @internal */
67
79
  export declare function paintWorldBarSamples(canvas: {
68
80
  width: number;
69
81
  height: number;
70
82
  getContext(kind: "2d"): CanvasRenderingContext2D | null;
71
- }, samples: readonly WorldBarSample[], dpr: number, barWidthPx?: number, barHeightPx?: number): void;
83
+ }, samples: readonly WorldBarSample[], dpr: number, maxBarWidthPx?: number, maxBarHeightPx?: number): void;
@@ -32,6 +32,18 @@ function projectAnchor(entity, height, camera, project) {
32
32
  return null;
33
33
  return anchor;
34
34
  }
35
+ /**
36
+ * Screen pixels per world metre at `anchor`: project a point one metre along the camera's right
37
+ * axis and measure the gap. Call after reading the anchor's own projection — `project` is reused.
38
+ */
39
+ function pixelsPerMeter(anchor, camera, project, viewport, x, y) {
40
+ const m = camera.matrixWorld.elements;
41
+ project.set(anchor[0] + m[0], anchor[1] + m[1], anchor[2] + m[2]).project(camera);
42
+ const px = (project.x * 0.5 + 0.5) * viewport.width;
43
+ const py = (-project.y * 0.5 + 0.5) * viewport.height;
44
+ const scale = Math.hypot(px - x, py - y);
45
+ return Number.isFinite(scale) ? scale : 0;
46
+ }
35
47
  function healthFraction(stat) {
36
48
  const range = stat.max - stat.min;
37
49
  return range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
@@ -72,11 +84,14 @@ export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole,
72
84
  break;
73
85
  if (occlude && worldBarOccluded(ctx, origin, anchor))
74
86
  continue;
87
+ const x = (project.x * 0.5 + 0.5) * viewport.width;
88
+ const y = (-project.y * 0.5 + 0.5) * viewport.height;
75
89
  into.push({
76
90
  entityId: id,
77
- x: (project.x * 0.5 + 0.5) * viewport.width,
78
- y: (-project.y * 0.5 + 0.5) * viewport.height,
91
+ x,
92
+ y,
79
93
  percent: healthFraction(stat),
94
+ pxPerMeter: pixelsPerMeter(anchor, camera, project, viewport, x, y),
80
95
  });
81
96
  }
82
97
  return into.length;
@@ -91,10 +106,12 @@ export function refreshWorldBarSamples(ctx, height, camera, viewport, samples, p
91
106
  const entity = eligibleEntity(ctx, sample.entityId, options);
92
107
  if (entity === null || Math.hypot(entity.position[0] - origin[0], entity.position[1] - origin[1], entity.position[2] - origin[2]) > maxDistance)
93
108
  continue;
94
- if (projectAnchor(entity, height, camera, project) === null)
109
+ const anchor = projectAnchor(entity, height, camera, project);
110
+ if (anchor === null)
95
111
  continue;
96
112
  sample.x = (project.x * 0.5 + 0.5) * viewport.width;
97
113
  sample.y = (-project.y * 0.5 + 0.5) * viewport.height;
114
+ sample.pxPerMeter = pixelsPerMeter(anchor, camera, project, viewport, sample.x, sample.y);
98
115
  samples[kept++] = sample;
99
116
  }
100
117
  samples.length = kept;
@@ -138,19 +155,34 @@ export function collectNameplateSamples(ctx, statId, height, roles, resolveRole,
138
155
  }
139
156
  return into.length;
140
157
  }
158
+ /** World width a health bar spans over its entity, in metres. */
159
+ const WORLD_BAR_METERS = 1.4;
160
+ const MIN_BAR_WIDTH_PX = 28;
161
+ /**
162
+ * Painted bar size for a sample: about {@link WORLD_BAR_METERS} wide at the anchor's on-screen
163
+ * scale, clamped between a still-readable minimum and the configured maximum, with height in
164
+ * proportion. Samples without a scale (caller-painted screen samples) keep the maximum.
165
+ * @internal
166
+ */
167
+ export function worldBarSize(pxPerMeter, maxWidthPx = 112, maxHeightPx = 10) {
168
+ if (pxPerMeter === undefined || !(pxPerMeter > 0))
169
+ return { width: maxWidthPx, height: maxHeightPx };
170
+ const width = Math.max(Math.min(MIN_BAR_WIDTH_PX, maxWidthPx), Math.min(maxWidthPx, pxPerMeter * WORLD_BAR_METERS));
171
+ const height = Math.max(4, Math.min(maxHeightPx, Math.round(width / 11)));
172
+ return { width: Math.round(width), height };
173
+ }
141
174
  /** @internal */
142
- export function paintWorldBarSamples(canvas, samples, dpr, barWidthPx = 112, barHeightPx = 10) {
175
+ export function paintWorldBarSamples(canvas, samples, dpr, maxBarWidthPx = 112, maxBarHeightPx = 10) {
143
176
  const g = canvas.getContext("2d");
144
177
  if (g === null)
145
178
  return;
146
179
  g.setTransform(1, 0, 0, 1, 0, 0);
147
180
  g.clearRect(0, 0, canvas.width, canvas.height);
148
181
  g.setTransform(dpr, 0, 0, dpr, 0, 0);
149
- const halfW = barWidthPx / 2;
150
- const halfH = barHeightPx / 2;
151
182
  for (const sample of samples) {
152
- const left = sample.x - halfW;
153
- const top = sample.y - halfH;
183
+ const { width: barWidthPx, height: barHeightPx } = worldBarSize(sample.pxPerMeter, maxBarWidthPx, maxBarHeightPx);
184
+ const left = sample.x - barWidthPx / 2;
185
+ const top = sample.y - barHeightPx / 2;
154
186
  g.fillStyle = "rgba(0,0,0,0.7)";
155
187
  g.strokeStyle = "rgba(0,0,0,0.7)";
156
188
  g.lineWidth = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,13 +31,13 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
34
- "check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json",
34
+ "check-types": "tsgo --noEmit -p tsconfig.json",
35
35
  "test": "bun test src"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.22.0",
39
- "@jgengine/react": "^0.22.0",
40
- "@jgengine/ws": "^0.22.0"
38
+ "@jgengine/core": "^0.23.0",
39
+ "@jgengine/react": "^0.23.0",
40
+ "@jgengine/ws": "^0.23.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@react-three/drei": "^10.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.22.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.23.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -65,8 +65,8 @@
65
65
  - `CommandDecoder` (type): type CommandDecoder<TInput> = (input: unknown) => CommandDecodeResult<TInput> — Parses raw `unknown` transport input into `TInput`, rejecting anything that doesn't match the command's declared shape. Runs before `validate`/`apply`, so a malformed payload never reaches game logic.
66
66
  - `CommandDefinition` (interface): interface CommandDefinition<TState, TInput> { decode?: CommandDecoder<TInput>; validate?(state: TState, input: TInput): CommandRejection | null; apply(state: TState, input: TInput): TState | void } — ⚠ undocumented
67
67
  - `CommandRegistry` (interface): interface CommandRegistry<TState> { define<TInput>(name: string, definition: CommandDefinition<TState, TInput>): void; has(name: string): boolean; names(): string[]; run(state: TState, name: string, input: unknown): CommandResult<TState> } — ⚠ undocumented
68
- - `CommandRejection` (interface): interface CommandRejection { reason: string } — ⚠ undocumented · used by `validateCommandInput` (@jgengine/ws): Validates a `runCommand` input against a declared catalog.
69
- - `CommandResult` (type): type CommandResult<TState> = | { status: "applied"; state: TState } | { status: "rejected"; reason: string } | { status: "unknown-command" } — ⚠ undocumented
68
+ - `CommandRejection` (interface): interface CommandRejection — Why a command refused: `reason` is a stable machine code; `message`, when set, is shown to the player.
69
+ - `CommandResult` (type): type CommandResult<TState> = | { status: "applied"; state: TState } | { status: "rejected"; reason: string; message?: string } | { status: "unknown-command" } — ⚠ undocumented
70
70
  - `createCommandRegistry` (function): function createCommandRegistry<TState>(): CommandRegistry<TState> — ⚠ undocumented
71
71
 
72
72
  ## @jgengine/core/devtools/controls
@@ -232,7 +232,7 @@
232
232
 
233
233
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
234
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
235
- - `VERSION` (const): const VERSION: "0.22.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
235
+ - `VERSION` (const): const VERSION: "0.23.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
236
 
237
237
  ## @jgengine/core/runtime/adapter
238
238
 
@@ -292,9 +292,9 @@
292
292
  - `CommandDef` (type): type CommandDef<TInput = unknown> = { /** * `"server"` when `input` carries facts only the host may decide (unlock state, prices, multipliers): * public transports then refuse it and only trusted host calls such as `helpers.runCommand` run it. * Defaults to `"client"`, so every field of `input` must… — ⚠ undocumented · used by `defineCommand`: Declare a command with a typed input: `parse` narrows the wire value once, and `scope`, `validate` and `apply` receive the parsed type.
293
293
  - `CommandScope` (type): type CommandScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — Which slice of a server has to be hydrated before some work runs against it. Both fields default to "everything", which costs a read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch.
294
294
  - `CommandScopeDefinition` (type): type CommandScopeDefinition = Omit<CommandScope, "players"> & { players?: readonly string[] | "actor" } — Declarative read scope; actor is resolved before host hydration.
295
- - `CommandValidationError` (type): type CommandValidationError = { reason: string } — ⚠ undocumented
295
+ - `CommandValidationError` (type): type CommandValidationError = { reason: string; message?: string } — `reason` is a stable machine code; `message`, when set, is player-facing text the client shows on refusal.
296
296
  - `MALFORMED_COMMAND_INPUT_REASON` (const): const MALFORMED_COMMAND_INPUT_REASON: "Malformed command input" — Reason every host returns when a command's `parse` rejects its input.
297
- - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string } — ⚠ undocumented
297
+ - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string; message?: string } — ⚠ undocumented
298
298
  - `SERVER_ONLY_COMMAND_REASON` (const): const SERVER_ONLY_COMMAND_REASON: "Command is server-only" — Reason a public transport returns for a {@link CommandDef} marked `access: "server"`.
299
299
  - `defineCommand` (function): function defineCommand<TInput>(definition: CommandDef<TInput> & { parse: (input: unknown) => TInput | null }): CommandDef — Declare a command with a typed input: `parse` narrows the wire value once, and `scope`, `validate` and `apply` receive the parsed type. Returns the untyped {@link CommandDef} a runtime's command table holds.
300
300
 
@@ -321,7 +321,7 @@
321
321
  - `GameContextItemUse` (interface): interface GameContextItemUse { register(handlers: Record<string, ItemUseHandler<GameContext>>): void; registered(): string[]; can(input: ItemUseInput): ItemUseRejection | null; use(input: ItemUseInput): ItemUseResult<GameContext> } — ⚠ undocumented
322
322
  - `GameContextLoot` (interface): interface GameContextLoot { register(def: LootTableDef): void; has(id: string): boolean; roll(id: string, rng?: () => number): Drop[]; grantToPlayer(userId: string, drops: Drop[], source?: string): void } — ⚠ undocumented
323
323
  - `GameContextModels` (interface): interface GameContextModels — Per-kind render-model lookup for {@link GameContextOptions.models}; a resolved `ModelConfig` satisfies {@link ModelBodySource} structurally.
324
- - `GameContextObjectEntry` (interface): interface GameContextObjectEntry { proximityPrompt?: ProximityPrompt; breakable?: false | {baseBreakTime: number}; slotInventory?: InventoryLayout; verbs?: readonly ContextVerb[]; colliders?: EntityColliderSet; halfExtents?: readonly [number, number, number] } — ⚠ undocumented
324
+ - `GameContextObjectEntry` (interface): interface GameContextObjectEntry { proximityPrompt?: ProximityPrompt; breakable?: false | {baseBreakTime: number}; slotInventory?: InventoryLayout; verbs?: readonly ContextVerb[]; colliders?: EntityColliderSet; halfExtents?: readonly [number, number, number]; scenery?: boo… — ⚠ undocumented
325
325
  - `GameContextOptions` (interface): interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> { definition: GameDefinition<TAssetRef, TMultiplayer>; content: GameContextContent; player: {userId: string; isNew: boolean}; territory?: Territory; now?: () => number; occluder?: (from: EntityPosition, to: EntityPosition) => boolean; seed?… — ⚠ undocumented
326
326
  - `GameContextRace` (interface): interface GameContextRace { state(id: string, config?: RaceStateConfig): RaceState } — ⚠ undocumented
327
327
  - `GameContextTurn` (interface): interface GameContextTurn { loop(id: string, config?: TurnLoopConfig): TurnLoop } — ⚠ undocumented
@@ -352,7 +352,7 @@
352
352
  - `GameContextItemUse` (interface): interface GameContextItemUse { register(handlers: Record<string, ItemUseHandler<GameContext>>): void; registered(): string[]; can(input: ItemUseInput): ItemUseRejection | null; use(input: ItemUseInput): ItemUseResult<GameContext> } — ⚠ undocumented
353
353
  - `GameContextLoot` (interface): interface GameContextLoot { register(def: LootTableDef): void; has(id: string): boolean; roll(id: string, rng?: () => number): Drop[]; grantToPlayer(userId: string, drops: Drop[], source?: string): void } — ⚠ undocumented
354
354
  - `GameContextModels` (interface): interface GameContextModels — Per-kind render-model lookup for {@link GameContextOptions.models}; a resolved `ModelConfig` satisfies {@link ModelBodySource} structurally.
355
- - `GameContextObjectEntry` (interface): interface GameContextObjectEntry { proximityPrompt?: ProximityPrompt; breakable?: false | {baseBreakTime: number}; slotInventory?: InventoryLayout; verbs?: readonly ContextVerb[]; colliders?: EntityColliderSet; halfExtents?: readonly [number, number, number] } — ⚠ undocumented
355
+ - `GameContextObjectEntry` (interface): interface GameContextObjectEntry { proximityPrompt?: ProximityPrompt; breakable?: false | {baseBreakTime: number}; slotInventory?: InventoryLayout; verbs?: readonly ContextVerb[]; colliders?: EntityColliderSet; halfExtents?: readonly [number, number, number]; scenery?: boo… — ⚠ undocumented
356
356
  - `GameContextOptions` (interface): interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> { definition: GameDefinition<TAssetRef, TMultiplayer>; content: GameContextContent; player: {userId: string; isNew: boolean}; territory?: Territory; now?: () => number; occluder?: (from: EntityPosition, to: EntityPosition) => boolean; seed?… — ⚠ undocumented
357
357
  - `GameContextRace` (interface): interface GameContextRace { state(id: string, config?: RaceStateConfig): RaceState } — ⚠ undocumented
358
358
  - `GameContextTurn` (interface): interface GameContextTurn { loop(id: string, config?: TurnLoopConfig): TurnLoop } — ⚠ undocumented
@@ -593,7 +593,7 @@
593
593
  - `PresenceSync` (type): type PresenceSync = { subscribe: (serverId: string, onChange: (rows: PresencePoseRow[]) => void) => FeedUnsubscribe; syncPose: (serverId: string, pose: PlayerPose) => void; } — ⚠ undocumented
594
594
  - `ResumeTicket` (type): type ResumeTicket = { userId: string; serverId: string; token: string; } — A short-lived credential allowing a disconnected client to reclaim one server membership.
595
595
  - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; } — ⚠ undocumented
596
- - `TransportRunCommandResult` (type): type TransportRunCommandResult = | { ok: true } | { ok: false; reason: string } — ⚠ undocumented
596
+ - `TransportRunCommandResult` (type): type TransportRunCommandResult = | { ok: true } /** `message` is the command's player-facing refusal text, when its `validate` gave one. */ | { ok: false; reason: string; message?: string } — ⚠ undocumented
597
597
  - `WorldSyncFrame` (type): type WorldSyncFrame = | { kind: "baseline"; revision: number; snapshot: WorldSnapshot } | { kind: "diff"; revision: number; diff: WorldDiff } — A revision-stamped world baseline or compact diff sent to a server-authoritative client.
598
598
  - `createTransportSessionId` (function): function createTransportSessionId(): string — Fresh id for one client session's join/leave pair (see {@link GameRuntimeTransport.joinServer}).
599
599
 
@@ -661,8 +661,8 @@
661
661
 
662
662
  ## @jgengine/shell/gameKit
663
663
 
664
- - `Clock` (function): function Clock({ format, showDay, controls, style, className, }: { format?: "24h" | "12h"; showDay?: boolean; controls?: boolean; style?: CSSProperties; className?: string; }): JSX.Element — A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so a game opts into letting the player scrub time.
665
- - `Coins` (function): function Coins({ currencyId, icon, style, className, }: { currencyId: string; icon?: ReactNode; style?: CSSProperties; className?: string; }): JSX.Element — A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
664
+ - `Clock` (function): function Clock({ format = "24h", showDay = true, controls = false, style, className, }: { format?: "24h" | "12h"; showDay?: boolean; controls?: boolean; style?: CSSProperties; className?: string; }): React.JSX.Element — A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so a game opts into letting the player scrub time.
665
+ - `Coins` (function): function Coins({ currencyId, icon = "🪙", style, className, }: { currencyId: string; icon?: ReactNode; style?: CSSProperties; className?: string; }): React.JSX.Element — A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
666
666
  - `DEFAULT_WALK_CODES` (const): const DEFAULT_WALK_CODES: { readonly moveForward: readonly ["KeyW"]; readonly moveBack: readonly ["KeyS"]; readonly moveLeft: readonly ["KeyA"]; readonly moveRight: readonly ["KeyD"]; readonly jump: readonly ["Space"]; } — Default keyboard codes for the shell walk controller (WASD + Space). Use as the base of `defineGame({ input: { ...DEFAULT_WALK_CODES, interact: ["KeyE"] } })` instead of re-typing the same five bindings in every game.
667
667
  - `EditorCatalogDefinition` (interface): interface EditorCatalogDefinition — Game-exported catalog definition: a `ParamSchema` plus default entries. Schemas stay in code; entry values merge into `document.catalogs` and are what the editor/RPC edits and saves.
668
668
  - `EditorDocument` (interface): interface EditorDocument — The full authored scene: every marker, volume, path, note, and sculpted terrain for a game.
@@ -673,21 +673,21 @@
673
673
  - `GameHost` (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer, editor, creator, creatorOpen = false, onCreatorOpenChange }: GameHostProps): React.JSX.Element — The one documented mount: resolves multiplayer for the playable and renders the shell. With the `editor` loader prop it also owns the whole editor summon (F2+E, `?mode=editor`, dev save endpoint).
674
674
  - `GameHostProps` (interface): interface GameHostProps — Props for {@link GameHost}: the playable, optional multiplayer overrides, and the optional editor loader that enables the engine-owned F2+E summon.
675
675
  - `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
676
- - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: (item… — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
676
+ - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
677
677
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
678
- - `HudPanel` (function): function HudPanel({ id, anchor, order, compact: compactMode, chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack position within the region, a… — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
679
- - `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
680
- - `StatBar` (function): function StatBar({ statId, entityId, tone, label, showValue, width, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }): JSX.Element | null — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
678
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
679
+ - `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
680
+ - `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
681
681
  - `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
682
682
  - `SystemDefinition` (interface): interface SystemDefinition — A reusable game capability — lifecycle, timing, events, and optional save / replication / reset / disposal. Pass instances via `defineGame({ systems })`. Prefer one system per meaningful capability (`combat`, `quests`), not per micro-tick.
683
- - `authoredSpawnPosition` (function): function authoredSpawnPosition(document: AuthoredSpawnDocumentLike, options?: AuthoredSpawnOptions): [number, number, number] | null — Position of the authored spawn marker as a spawn-ready `[x, y, z]` tuple, or null when the document has none. Reads the first `player_spawn` marker by default, so dragging the marker in the editor moves where players spawn — no coordinates copied into game code.
683
+ - `authoredSpawnPosition` (function): function authoredSpawnPosition(document: AuthoredSpawnDocumentLike, options: AuthoredSpawnOptions = {}): [number, number, number] | null — Position of the authored spawn marker as a spawn-ready `[x, y, z]` tuple, or null when the document has none. Reads the first `player_spawn` marker by default, so dragging the marker in the editor moves where players spawn — no coordinates copied into game code.
684
684
  - `collectAuthoredTriggers` (function): function collectAuthoredTriggers(document: SceneDocumentLike): AuthoredTrigger[] — Collect every authored trigger on a document's markers and volumes. Pure — no runtime state. Action params use the live {@link registerTriggerAction} registry when present.
685
685
  - `defineGame` (function): function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(config: GameConfig<TAssetRef>): PlayableGame — The one public authoring entry point: compose engine fields (systems, world, physics, input) and presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`. Defaults to solo/offline multiplayer; `editorLayers` auto-mounts the authored scene document.
686
686
  - `defineKeyedStore` (function): function defineKeyedStore<T>(keyFor: (id: string) => string, initial: T | (() => T)): KeyedStoreHandle<T> — Define a typed per-owner keyed family on the game store: game code expresses `read(ctx, userId)`/`write(ctx, userId, v)`/`update(...)` with the value type it means, and both the `` `prefix:${id}` `` key composition and the `unknown → T` cast live once, here, behind the boundary. Reach for this over N separate `defineStore` calls whenever the owning id varies at runtime (per-user class, per-instance auras) — `defineStore` stays the right call for a single fixed slot.
687
687
  - `defineStore` (function): function defineStore<T>(key: string, initial: T | (() => T)): StoreHandle<T> — Define a typed slot on the game store: game code expresses `read(ctx)`/`write(ctx, v)`/`update(...)` with the value type it means, and the `unknown → T` cast lives once, here, behind the boundary. Pass a factory for `initial` when the fallback is a fresh mutable object; the factory runs at most once and its result is reused, so an unwritten slot keeps a stable identity across reads (no per-read churn for a React selector, no allocation on a hot path). A value that carries functions (a closure-based run store, an input handle) is runtime-only: snapshots, saves and replication skip it and hydration keeps it in place. Hold such objects with `perContext` when they need no store notifications.
688
688
  - `defineSystem` (function): function defineSystem(definition: SystemDefinition): SystemDefinition — Declare a composable game system. Pure data + hooks — the engine compiles the schedule and installs lifecycle when the game boots.
689
- - `environment` (function): function environment(config?: EnvironmentWorldConfig): EnvironmentWorldFeature — Composes an `environment()` feature from terrain, sky, weather, vegetation, water, structures, roads, and pads.
690
- - `environmentContentFromDocument` (function): function environmentContentFromDocument(doc: EditorDocument, options?: EnvironmentContentOptions): EnvironmentContent — Derives the coordinate/placement content of an `environment()` world from its scene document: terrain footprint (via {@link terrainBoundsFromDocument}), ground clearings under authored spawns/POIs (via `clearanceZonesFrom`), and the document's sculpt snapshot. This is the seam that lets a game author its world footprint and flatten regions in the editor instead of hardcoding them — the `world.ts` that consumes it carries only engine tuning.
689
+ - `environment` (function): function environment(config: EnvironmentWorldConfig = {}): EnvironmentWorldFeature — Composes an `environment()` feature from terrain, sky, weather, vegetation, water, structures, roads, and pads.
690
+ - `environmentContentFromDocument` (function): function environmentContentFromDocument(doc: EditorDocument, options: EnvironmentContentOptions = {}): EnvironmentContent — Derives the coordinate/placement content of an `environment()` world from its scene document: terrain footprint (via {@link terrainBoundsFromDocument}), ground clearings under authored spawns/POIs (via `clearanceZonesFrom`), and the document's sculpt snapshot. This is the seam that lets a game author its world footprint and flatten regions in the editor instead of hardcoding them — the `world.ts` that consumes it carries only engine tuning.
691
691
  - `grounded` (function): function grounded(ctx: Pick<GameContext, "world">, x: number, z: number): readonly [number, number, number] — Ground an XZ point onto the live world surface as `[x, groundY, z]`.
692
692
  - `offline` (function): function offline(): MultiplayerAdapterConfig — Explicit single-player adapter. Solo games never need this — omitting `multiplayer` in the shell `defineGame` already defaults to offline; pass it only where an adapter value is structurally required.
693
693
  - `seededRng` (function): function seededRng(seed: string | number): SeededRng — Deterministic pseudo-random generator seeded from a string or number — same seed, same sequence.
@@ -32,6 +32,8 @@ Existing React games keep their entity store and use the focused
32
32
  - **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
33
33
  - **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned.
34
34
  - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win.
35
+ - **The shell applies `defaultHudTheme` at the GameUI root.** `HudThemeRoot` wraps every `GameUI` in a `display: contents` `[data-jg-hud]` element carrying the theme vars and injects the engine stylesheet (`jg-*` keyframes, `[data-jg-*]` interaction states, reduced motion). Set `defineGame({ hudTheme })` to swap the root theme, `hudTheme: false` to opt out; a theme spread inside `GameUI` still wins. Games outside the shell add `@import "@jgengine/react/styles.css";` to `src/index.css` (the `jgengine create` template does). The theme also carries a type scale with a 12px floor, spacing, motion, elevation, and hover/press/focus/disabled tokens; read them in inline styles through `hudType`, `hudSpace`, `hudTransition`, `hudElevation`, and `HUD_FOCUS_RING` (`@jgengine/react/hudTokens`) — never hardcode text under 12px.
36
+ - **Build interactive chrome from `HudButton`, `HudPanel`, and `TweenNumber`.** `HudButton` gives hover lift, press scale, focus ring, and disabled from tokens; `HudPanel` gives elevation, header slots, and an `open` enter/exit transition; `TweenNumber` / `useTweenedValue` roll counters with an up/down delta flash. Each takes `cue` (`data-jg-cue`) and `onInteract`; `HudInteractProvider` routes every primitive's hover/press/activate/open/close to one listener; the shell mounts one that plays the matching UI cue, so engine primitives sound with no wiring (`cue="confirm"` picks the activate sound).
35
37
  - **Paint slot icons with `IconTreatment`, never render raw `itemId` strings.** It draws a `GameIcon` glyph over a school/element-keyed gradient with gloss, vignette, and optional count/keycap badges, reading the `--jg-slot-*` / `--jg-accent` tokens. `treatedItemIcon(itemId)` / `treatedActionIcon(action)` are ready adapters for hotbars and ability bars.
36
38
  - **Player death gets a visible screen and respawn feedback — never a silent teleport.** Build the moment `game-design` requires: an authored death/downed overlay that reads the lethal event, communicates the consequence, and surfaces the way back (respawn/revive/restart). Drive it from `createDownedState` (`@jgengine/core/combat`) rather than a hand-rolled flag, and mount it inside `HudCanvas`.
37
39
  - **Browser interruption listeners are shared.** Use `useBrowserSuspension` from `@jgengine/react/browserLifecycle` for blur, hidden-page and pointer-lock loss. Keep the decision to pause, release controls or preserve multiplayer simulation in the callback; use options to disable unwanted triggers.
@@ -41,6 +43,8 @@ Existing React games keep their entity store and use the focused
41
43
 
42
44
  ## Shared-world interaction rules
43
45
 
46
+ - **Refusals and notices are engine feedback, not per-game toasts.** Return `{ reason: "insufficient-gold", message: "Needs 40 more gold" }` from a command's `validate`: the shell shows `message` in the shared notice feed with the deny cue and stacks repeats. Push other player messages with `ctx.game.notices.push({ text, tone })` (info/success/warn/deny) from game logic instead of a hand-built toast list. Tune or hide the stock feed with `notices: { placement, hidden }` (`hidden` keeps cues and lets the game render `useNoticeList(ctx.game.notices)` itself).
47
+ - **UI sounds come from `useUiCue()`.** Engine primitives already sound through the shell; for game-owned markup call `cue("click" | "hover" | "open" | "close" | "confirm" | "deny" | "notify")` from buttons and panels; replace a default by declaring `audio.sounds["ui.click"]`, or silence with `audio.uiCues: { hover: false }` / `false`.
44
48
  - Every rejected verb displays its reason on the triggering surface. Use `useServerSession(transport, gameId)` for join lifecycle. Join failure blocks play with `JoinGate` and retry; returning `{ ok: false }` without feedback is incomplete.
45
49
  - Placement previews display claim cost and affordability inline with `TerritoryOverlay`.
46
50
  - `ChatPanel` owns collapsed/expanded state, own-message styling, Enter focus and Escape blur; standalone hosts pass `messages`, `userId`, and `onSend` (typed `{ ok, reason }`), while context-backed games use channel props. Use native inputs so shell typing-target suspension applies.