@jgengine/react 0.32.0 → 0.34.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.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,64 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.34.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.34.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.31.0`; `@jgengine/github` is `0.21.0`.
34
+
35
+ ### Added
36
+
37
+ - `createRunLoop` (`@jgengine/core/run/runLoop`) holds a run's at-risk haul, banks it scaled by the line or loses it on a failure, and draws one authored change for each next run from a seeded table, so the same seed and run history replay the same sequence. State round-trips through `snapshot`/`restore`; the money stays in `progression/careerBank`.
38
+
39
+ ## 0.33.0
40
+
41
+ ### Migrate
42
+
43
+ - **Bump lockstep SDK packages to `^0.33.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.
44
+ - `useSceneObjects` (`@jgengine/react/hooks`) keeps one list identity across `move` and `rotate`, so a record's `position`/`rotationY` is what it was at the last render. Read the live placement with `ctx.scene.object.get(instanceId)` (a `useFrame` loop or a rAF-ticked HUD) when a mesh or readout must follow a moving object.
45
+ - A read of a mismatched stored value now converts losslessly where it can (a number as its digits, a boolean as `"true"`/`"false"`, those strings back) and throws where it cannot. No in-repo caller relied on the old fallback; `useSetting` (`@jgengine/react/settings`) is the generic path that could, so a game that stored a number under a key it reads with a string fallback now gets the digits.
46
+
47
+ ### Added
48
+
49
+ - `releasePostfxOverlayTracking(scene)` (`@jgengine/shell/postfx/postfxOverlay`) drops the cached prepass overlay set and its listeners for a scene that is being torn down.
50
+ - `canvasCover` (`@jgengine/react/canvasCover`): one page-wide hold a covering surface takes, plus `useCanvasCover(declared)` for a React overlay. While it is held the shell stops drawing the world, its shadow passes and postfx; input, audio and the DOM HUD keep running, and the first frame after release draws the untouched scene. A scrim or side panel must not take it.
51
+ - `MenuScreen` and `TitleMenu` take `coversCanvas` (default false) to declare that the screen paints over the whole canvas. A menu with only a translucent scrim leaves the live scene drawing.
52
+ - The dev runner pins the cover with `?cover=1`, and the devtools agent bridge answers `canvas_cover`, so a perf run or `bun run drive` can measure a covered frame on any game.
53
+ - `combat.damage` carries an optional `position`: the effect path fills it from the target's own spot, and `createDamageRuntime` takes one per hit (`hit({ position })`) or from a `positionOf` reader, so a status tick is placed too. A cue with a position pans and attenuates; one without stays centred.
54
+ - `createHapticsRoute` (`@jgengine/shell/audio/hapticsRoute`) sends a feedback pulse to a connected gamepad, and to `navigator.vibrate` on a phone with no pad. `FeedbackHost` wires it; reduced motion still drops the camera shake only.
55
+ - `PerfReport.render.drawsFrame` is every draw a frame issues — shadow, main, postfx prepasses, AO and DOF together — as the 95th percentile of the measured window, with `drawsFrames` naming how many frames it covers. `bun run perf:gate` compares it against each game's re-based `drawsFrame` budget; the renderer's own single-sample `draws` is printed beside it and is no longer gated.
56
+ - `ObjectStore.version()` / `ObjectStore.markSpatialChange()` (`ctx.scene.object`) — a counter that moves whenever membership, a position, a rotation or a consumer-owned collider set changes, so derived spells spatial data can share one cache key.
57
+ - `SolidObstacleSource.version?()` (`@jgengine/core/movement/solidObstacles`) — a source that reports one gets its blocking obstacles resolved once per version into a coarse XZ index; a source without it keeps the per-query `inBox` gather.
58
+ - `SettingsTypeError` and `asSettingType` (`@jgengine/core/settings/settingsModel`) report and resolve a stored setting read as another type.
59
+
60
+ ### Changed
61
+
62
+ - `bun run land` queues landings on one lock per repo instead of running them beside each other, so two lanes no longer stack two pull requests' sixteen jobs against the account's 20-job cap. A waiting landing names the branch and pid it waits on; a lock whose process died, or that is older than 30 minutes, is broken with a warning.
63
+ - The scene io browser test skips Playwright's two-frame click stability check and lets three rendered frames pass instead of ten: on a runner its second test went from 53s to 11-24s and the file from 76s to 50s, of which 16-36s is the fixture's own start.
64
+ - `check-game-front-end` recognizes the shipped `CreditsPanel`, so a game that renders credits through the engine's panel is no longer flagged as having no credits screen.
65
+ - The shell's frame loop switches to `frameloop="never"` under a cover instead of drawing a world nobody can see (measured on Brightway Park's pause menu: 1,095 whole-frame draws and 5.2 ms/frame before, 0 draws and 0.11 ms/frame covered). A frame-rate cap no longer advances a covered canvas.
66
+ - `FeedbackDeps.play` takes an optional `position`, which `FeedbackHost` forwards to `ctx.game.audio.play`, so a positioned damage or death cue reaches the spatial engine.
67
+ - Each game's `perf-budget.json` entry now budgets `drawsFrame` and records the budget it replaced as `previousDraws`; an entry that still carries only `draws` fails by name instead of gating a count from a different measurement.
68
+ - The in-page report reads the devtools render probe's frame sample instead of the live renderer counter, so `window.__JG.report()` and the devtools panel no longer report a mid-frame partial count.
69
+ - Movement gathers obstacles from that index instead of re-resolving every nearby object every frame. On Harbor Heat the query fell from 0.62-0.65 ms to 0.10 ms a frame (2.0-2.1 ms to 1.4 ms of simulation a frame).
70
+ - `objectStore.inBox` walks a coarse grid for big boxes instead of scanning every placed object; a 1 m cell lookup costs a string hash, so the switch happens past 32 cells rather than 4096.
71
+ - Ambient occlusion reads the beauty pass's depth buffer instead of rendering the scene a second time for its normal/depth GBuffer. That prepass cost 537 extra draws and 2.6-2.9 ms/frame in Harbor Heat and 2,105 extra draws in Drift Foundry at 1600x900; AO now draws nothing of its own. A post chain whose antialiasing is `"smaa"` (the default) keeps SMAA and drops the beauty target's extra MSAA resolve, because WebGL2 cannot sample a multisampled depth texture; `"msaa"` chains keep the old prepass. Fixed-viewpoint captures differ only on geometry edges (#2173).
72
+
73
+ ### Fixed
74
+
75
+ - Postfx prepasses no longer walk the whole scene twice a frame to hide overlays, points and lines: the set is cached per scene and rescanned when the scene graph changes. Drift Foundry 2.11 ms/frame and Harbor Heat 0.66 ms/frame of `traverse` removed.
76
+ - A rig posed from an animation graph no longer allocates a motion sample, a position triple and a fresh pose record every frame, and foot IK no longer allocates a probe input, filter, closure and per-leg records every frame.
77
+ - The Graphics tab lists a post-processing stage toggle only when the game's resolved chain can build that stage. Depth of field, which no reference game authors, hid behind a row that switched a pass the composer never created, so the toggle changed nothing. The rule covers every stage: a chain with `ao: false`, `bloom: false` or `aa: "msaa"`/`false` no longer offers those rows, and a game with no post chain gets none of them. Every other settings row is unchanged (`@jgengine/shell/settings/settingsController`, `availablePostfxStages` in `@jgengine/shell/postfx/PostProcessing`).
78
+ - Positional loops declared through `entitySounds`/`objectSounds` are tracked instead of rebuilt: spawn, despawn and a moved emitter update one retained loop each, and a frame where nothing moved reads only the emitters that already sound. On scrap-signal's 560 placed props (69 sounding), the tracking path drops from 16.7 µs to 1.2 µs per frame and a steady frame allocates no Set, array or record.
79
+ - `useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` show a setting written before the component subscribed — a saved profile or the demo path seeding values before first paint — instead of waiting for the player's next change (#2180).
80
+ - `useFeed`, `useQuestJournal` and `useFriends` (`@jgengine/react/hooks`) keep one list identity while membership holds instead of allocating a new array on every store write and comparing it with `Object.is`, so a HUD reading them no longer re-renders on every entity pose write. A pushed feed entry, an accepted, progressed or abandoned quest, and an accepted or removed friend each re-render once. Measured on the three read sites (`ToastStack`, `FriendsList`, `JournalQuestTracker`) with 15 quests, 20 friends and 20 feed rows: 900 renders → 0 across 300 pose writes, 0.14 ms/frame → 0.04 ms/frame.
81
+ - `useWorldItems` (`@jgengine/react/hooks`) keeps one list identity until an item spawns, is picked up or is removed. It read `worldItem.list()`, which allocates a new array per call, against a store signal that also fires on every entity pose write, so a HUD re-rendered every dropped-item marker each frame (0.97 ms/frame of React work in Harbor Heat).
82
+ - `WorldItems` (`@jgengine/shell`) reads each marker's live pose in `useFrame` instead of taking it as a prop, so a moving item still follows without re-rendering its marker.
83
+ - `useSceneEntities` and `useSceneObjects` (`@jgengine/react/hooks`) keep one list identity while membership holds, instead of allocating a new array on every store write and comparing it with `Object.is`. A pose write no longer re-renders every HUD that reads them (30-entity roster: 0.066 ms/frame → 0; the whole loop 0.24 → 0.05 ms/frame), while a spawn, despawn, replace, place or remove still re-renders. Non-pose object writes (state, visual, animation, slots, catalog id) still re-render.
84
+ - `RevealHighlights` (`@jgengine/shell/vision`) drives its own per-frame refresh only while Dark Sight is enabled, so a disabled reveal overlay no longer re-renders (0.019 ms/frame → 0) and an enabled one still follows the origin and its targets.
85
+ - `createSettingsStore().get` returns a stored setting of another type instead of quietly returning the fallback, so a numeric setting read with a string fallback applies instead of vanishing (#2189). Root cause: `coerce` treated the fallback's type as the stored type and replaced every other type with the fallback. A stored value that cannot be read as the asked-for type now throws `SettingsTypeError` naming the key, the stored type and the fallback type.
86
+
29
87
  ## 0.32.0
30
88
 
31
89
  ### Migrate
@@ -1,10 +1,35 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { accessibilitySettingRows, readAccessibilitySettings, readHoldVsToggle, } from "@jgengine/core/settings/accessibilitySettings";
3
3
  import { useSettingsStore } from "./settings.js";
4
- function useSettingsRevision() {
5
- const store = useSettingsStore();
4
+ /** Every accessibility value as one comparable string, read with the same fallbacks the hooks use. */
5
+ function accessibilitySignature(store) {
6
+ const { state, holdVsToggle } = readAccessibilitySettings(store);
7
+ return JSON.stringify([
8
+ state.reducedMotion,
9
+ state.highContrast,
10
+ state.textScale,
11
+ state.colorblind,
12
+ state.captions,
13
+ holdVsToggle,
14
+ ]);
15
+ }
16
+ /**
17
+ * A counter that changes on every settings write, so the hooks below re-render. React runs this
18
+ * subscription in a passive effect, after the render that showed the old value; a write landing in
19
+ * that window (a saved profile or the demo path seeding settings) would otherwise stay invisible
20
+ * until the player changed something else. Reading the value again on subscribe closes the window.
21
+ */
22
+ function useSettingsRevision(store) {
6
23
  const [revision, setRevision] = useState(0);
7
- useEffect(() => store.subscribe(() => setRevision((value) => value + 1)), [store]);
24
+ const rendered = useRef("");
25
+ rendered.current = accessibilitySignature(store);
26
+ useEffect(() => {
27
+ const bump = () => setRevision((value) => value + 1);
28
+ const unsubscribe = store.subscribe(bump);
29
+ if (accessibilitySignature(store) !== rendered.current)
30
+ bump();
31
+ return unsubscribe;
32
+ }, [store]);
8
33
  return revision;
9
34
  }
10
35
  /**
@@ -16,7 +41,7 @@ function useSettingsRevision() {
16
41
  */
17
42
  export function useAccessibilitySettings() {
18
43
  const store = useSettingsStore();
19
- const revision = useSettingsRevision();
44
+ const revision = useSettingsRevision(store);
20
45
  return useMemo(() => readAccessibilitySettings(store).state, [store, revision]);
21
46
  }
22
47
  /**
@@ -27,7 +52,7 @@ export function useAccessibilitySettings() {
27
52
  */
28
53
  export function useHoldVsToggle() {
29
54
  const store = useSettingsStore();
30
- const revision = useSettingsRevision();
55
+ const revision = useSettingsRevision(store);
31
56
  return useMemo(() => readHoldVsToggle(store), [store, revision]);
32
57
  }
33
58
  /**
@@ -39,6 +64,6 @@ export function useHoldVsToggle() {
39
64
  */
40
65
  export function useAccessibilityRows() {
41
66
  const store = useSettingsStore();
42
- const revision = useSettingsRevision();
67
+ const revision = useSettingsRevision(store);
43
68
  return useMemo(() => accessibilitySettingRows(store), [store, revision]);
44
69
  }
@@ -0,0 +1,26 @@
1
+ /** A page-wide declaration that some surface paints over the whole game canvas. */
2
+ export interface CanvasCover {
3
+ /** True while at least one surface covers the canvas. */
4
+ covered(): boolean;
5
+ /** How many surfaces currently hold the cover. */
6
+ holders(): number;
7
+ subscribe(listener: () => void): () => void;
8
+ /**
9
+ * Declare the canvas fully covered until the returned function runs. Calling it twice declares
10
+ * twice; the cover lifts when the last hold releases.
11
+ */
12
+ hold(): () => void;
13
+ }
14
+ /**
15
+ * The page's canvas cover. A full-screen menu (or a game's own opaque overlay) holds it; the
16
+ * shell stops drawing the world, its shadow passes and postfx while it is held, and draws again
17
+ * from the untouched scene when it lifts. A translucent scrim or a side panel must not hold it —
18
+ * anything the scene shows through has to keep drawing.
19
+ *
20
+ * @capability canvas-cover stop world rendering behind a full-screen surface while input, audio, the HUD and the menu keep running
21
+ */
22
+ export declare const canvasCover: CanvasCover;
23
+ /** Declare the canvas fully covered while `declared` is true, and release on unmount. */
24
+ export declare function useCanvasCover(declared: boolean): void;
25
+ /** True while a surface covers the canvas. */
26
+ export declare function useCanvasCovered(): boolean;
@@ -0,0 +1,53 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ const listeners = new Set();
3
+ let holders = 0;
4
+ function notifyIfPresenceChanged(previous, next) {
5
+ if (previous > 0 === next > 0)
6
+ return;
7
+ for (const listener of [...listeners])
8
+ listener();
9
+ }
10
+ /**
11
+ * The page's canvas cover. A full-screen menu (or a game's own opaque overlay) holds it; the
12
+ * shell stops drawing the world, its shadow passes and postfx while it is held, and draws again
13
+ * from the untouched scene when it lifts. A translucent scrim or a side panel must not hold it —
14
+ * anything the scene shows through has to keep drawing.
15
+ *
16
+ * @capability canvas-cover stop world rendering behind a full-screen surface while input, audio, the HUD and the menu keep running
17
+ */
18
+ export const canvasCover = {
19
+ covered: () => holders > 0,
20
+ holders: () => holders,
21
+ subscribe(listener) {
22
+ listeners.add(listener);
23
+ return () => {
24
+ listeners.delete(listener);
25
+ };
26
+ },
27
+ hold() {
28
+ const previous = holders;
29
+ holders += 1;
30
+ notifyIfPresenceChanged(previous, holders);
31
+ let released = false;
32
+ return () => {
33
+ if (released)
34
+ return;
35
+ released = true;
36
+ const was = holders;
37
+ holders = Math.max(0, holders - 1);
38
+ notifyIfPresenceChanged(was, holders);
39
+ };
40
+ },
41
+ };
42
+ /** Declare the canvas fully covered while `declared` is true, and release on unmount. */
43
+ export function useCanvasCover(declared) {
44
+ useEffect(() => {
45
+ if (!declared)
46
+ return;
47
+ return canvasCover.hold();
48
+ }, [declared]);
49
+ }
50
+ /** True while a surface covers the canvas. */
51
+ export function useCanvasCovered() {
52
+ return useSyncExternalStore(canvasCover.subscribe, canvasCover.covered, () => false);
53
+ }
package/dist/hooks.d.ts CHANGED
@@ -42,7 +42,21 @@ export declare function useGamePhase(): {
42
42
  };
43
43
  /** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
44
44
  export declare function useOptionalGamePhase(): GamePhase;
45
+ /**
46
+ * Live entities: the returned list keeps one identity until an entity spawns, despawns, is replaced
47
+ * or the store is hydrated — a pose write does not re-render its readers (#2212). `entity.list()`
48
+ * allocates a new array per write against an `Object.is` comparison, so every pose write re-rendered
49
+ * every HUD reading this hook. The records themselves are mutated in place, so a reader that needs a
50
+ * pose right now can still take `entity.position`; render-loop work reads it in `useFrame`.
51
+ */
45
52
  export declare function useSceneEntities(): readonly SceneEntity[];
53
+ /**
54
+ * Placed objects: the returned list keeps one identity across move and rotate writes, so a HUD
55
+ * reading it no longer re-renders on every pose write (#2212). Place, remove and the non-pose fields
56
+ * (catalog id, parent space, animation, visual, state, slots) still re-render. `move`/`rotate`
57
+ * replace the record, so take live placement from `ctx.scene.object.get(instanceId)` rather than
58
+ * the record's `position`.
59
+ */
46
60
  export declare function useSceneObjects(): readonly SceneObject[];
47
61
  /**
48
62
  * Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
@@ -53,6 +67,12 @@ export declare function useSceneObjects(): readonly SceneObject[];
53
67
  export declare function useSceneEntityIds(): readonly string[];
54
68
  /** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
55
69
  export declare function useSceneObjectIds(): readonly string[];
70
+ /**
71
+ * Live dropped-item records: the returned list keeps one identity until an item spawns, is picked up
72
+ * or is removed. `worldItem.list()` allocates a new array per call and the shared store signal fires
73
+ * on every pose write, so a raw read re-rendered every marker each frame (#2175). A marker reads its
74
+ * own live pose imperatively (useFrame).
75
+ */
56
76
  export declare function useWorldItems(): readonly WorldItemRecord[];
57
77
  /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
58
78
  export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
@@ -76,11 +96,25 @@ export interface InventoryGridBinding {
76
96
  */
77
97
  export declare function useInventoryGrid(inventoryId: string): InventoryGridBinding;
78
98
  export declare function useCurrency(currencyId: string): number;
99
+ /**
100
+ * Recent feed entries for `action`, newest last: the list keeps one identity until an entry is pushed,
101
+ * so a HUD ticker or kill feed no longer re-renders on every pose write (#2216).
102
+ */
79
103
  export declare function useFeed({ action, limit }: {
80
104
  action: string;
81
105
  limit?: number;
82
106
  }): FeedEntry[];
107
+ /**
108
+ * The local player's journal: the list keeps one identity while the accepted quests, their status and
109
+ * their objective progress are unchanged, so a tracker HUD no longer re-renders on every pose write
110
+ * (#2216). Accepting, progressing, completing or revoking a quest re-renders once.
111
+ */
83
112
  export declare function useQuestJournal(): QuestInstance[];
113
+ /**
114
+ * The local player's friends with their presence: the list keeps one identity until a request is
115
+ * accepted, a friend is removed or a friend's presence flips, so a friends HUD no longer re-renders on
116
+ * every pose write (#2216).
117
+ */
84
118
  export declare function useFriends(): FriendEntry[];
85
119
  export declare function useParty(): PartyMemberEntry[];
86
120
  export declare function usePresence(userId: string): PresenceInfo;
package/dist/hooks.js CHANGED
@@ -44,11 +44,56 @@ export function useOptionalGamePhase() {
44
44
  const getSnapshot = useCallback(() => (ctx === null ? "playing" : gamePhase(ctx)), [ctx]);
45
45
  return useSyncExternalStore(ctx?.subscribe ?? noopSubscribe, getSnapshot, getSnapshot);
46
46
  }
47
+ /** Same records in the same order: the list keeps one identity while membership holds (#2211, #2212). */
48
+ function sameRecordList(previous, next) {
49
+ if (previous.length !== next.length)
50
+ return false;
51
+ for (let index = 0; index < previous.length; index += 1) {
52
+ if (previous[index] !== next[index])
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+ /**
58
+ * Object records are replaced on every write, so compare the placement and leave pose out: a move or
59
+ * rotate keeps the list identity. Read a live pose with `ctx.scene.object.get(instanceId)`.
60
+ */
61
+ function sameObjectPlacements(previous, next) {
62
+ if (previous.length !== next.length)
63
+ return false;
64
+ for (let index = 0; index < previous.length; index += 1) {
65
+ const before = previous[index];
66
+ const after = next[index];
67
+ if (before === after)
68
+ continue;
69
+ if (before.instanceId !== after.instanceId || before.catalogId !== after.catalogId)
70
+ return false;
71
+ if (before.parentSpace !== after.parentSpace || before.animation !== after.animation)
72
+ return false;
73
+ if (before.visual !== after.visual || before.state !== after.state || before.slots !== after.slots)
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+ /**
79
+ * Live entities: the returned list keeps one identity until an entity spawns, despawns, is replaced
80
+ * or the store is hydrated — a pose write does not re-render its readers (#2212). `entity.list()`
81
+ * allocates a new array per write against an `Object.is` comparison, so every pose write re-rendered
82
+ * every HUD reading this hook. The records themselves are mutated in place, so a reader that needs a
83
+ * pose right now can still take `entity.position`; render-loop work reads it in `useFrame`.
84
+ */
47
85
  export function useSceneEntities() {
48
- return useGameStore((ctx) => ctx.scene.entity.list());
86
+ return useGameStore((ctx) => ctx.scene.entity.list(), sameRecordList);
49
87
  }
88
+ /**
89
+ * Placed objects: the returned list keeps one identity across move and rotate writes, so a HUD
90
+ * reading it no longer re-renders on every pose write (#2212). Place, remove and the non-pose fields
91
+ * (catalog id, parent space, animation, visual, state, slots) still re-render. `move`/`rotate`
92
+ * replace the record, so take live placement from `ctx.scene.object.get(instanceId)` rather than
93
+ * the record's `position`.
94
+ */
50
95
  export function useSceneObjects() {
51
- return useGameStore((ctx) => ctx.scene.object.list());
96
+ return useGameStore((ctx) => ctx.scene.object.list(), sameObjectPlacements);
52
97
  }
53
98
  /**
54
99
  * Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
@@ -67,8 +112,14 @@ export function useSceneObjectIds() {
67
112
  const object = ctx.scene.object;
68
113
  return useSyncExternalStore(object.subscribeMembership, object.ids, object.ids);
69
114
  }
115
+ /**
116
+ * Live dropped-item records: the returned list keeps one identity until an item spawns, is picked up
117
+ * or is removed. `worldItem.list()` allocates a new array per call and the shared store signal fires
118
+ * on every pose write, so a raw read re-rendered every marker each frame (#2175). A marker reads its
119
+ * own live pose imperatively (useFrame).
120
+ */
70
121
  export function useWorldItems() {
71
- return useGameStore((ctx) => ctx.scene.worldItem.list());
122
+ return useGameStore((ctx) => ctx.scene.worldItem.list(), sameRecordList);
72
123
  }
73
124
  /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
74
125
  export function useNearestWorldItem(radius) {
@@ -112,12 +163,51 @@ export function useInventoryGrid(inventoryId) {
112
163
  export function useCurrency(currencyId) {
113
164
  return useGameStore((ctx) => ctx.game.economy.balance(ctx.player.userId, currencyId));
114
165
  }
166
+ /** Feed entries are immutable once pushed, so a bound list is the same entries in the same order (#2216). */
167
+ function sameFeedEntries(previous, next) {
168
+ return sameRecordList(previous, next);
169
+ }
170
+ /**
171
+ * Recent feed entries for `action`, newest last: the list keeps one identity until an entry is pushed,
172
+ * so a HUD ticker or kill feed no longer re-renders on every pose write (#2216).
173
+ */
115
174
  export function useFeed({ action, limit }) {
116
- return useGameStore((ctx) => ctx.game.feed.recent(action, limit === undefined ? undefined : { limit }));
175
+ return useGameStore((ctx) => ctx.game.feed.recent(action, limit === undefined ? undefined : { limit }), sameFeedEntries);
117
176
  }
118
177
  const EMPTY_QUESTS = [];
178
+ /** `list()` builds fresh rows per call, so compare the fields it emits and leave nothing else to identity. */
179
+ function sameQuestViews(previous, next) {
180
+ if (previous.length !== next.length)
181
+ return false;
182
+ for (let index = 0; index < previous.length; index += 1) {
183
+ const before = previous[index];
184
+ const after = next[index];
185
+ if (before === after)
186
+ continue;
187
+ if (before.questId !== after.questId || before.status !== after.status)
188
+ return false;
189
+ if (before.objectives.length !== after.objectives.length)
190
+ return false;
191
+ for (let row = 0; row < before.objectives.length; row += 1) {
192
+ const was = before.objectives[row];
193
+ const is = after.objectives[row];
194
+ if (was === is)
195
+ continue;
196
+ if (was.id !== is.id || was.kind !== is.kind || was.count !== is.count)
197
+ return false;
198
+ if (was.progress !== is.progress || was.complete !== is.complete)
199
+ return false;
200
+ }
201
+ }
202
+ return true;
203
+ }
204
+ /**
205
+ * The local player's journal: the list keeps one identity while the accepted quests, their status and
206
+ * their objective progress are unchanged, so a tracker HUD no longer re-renders on every pose write
207
+ * (#2216). Accepting, progressing, completing or revoking a quest re-renders once.
208
+ */
119
209
  export function useQuestJournal() {
120
- return useGameStore((ctx) => ctx.game.quest?.list(ctx.player.userId) ?? EMPTY_QUESTS);
210
+ return useGameStore((ctx) => ctx.game.quest?.list(ctx.player.userId) ?? EMPTY_QUESTS, sameQuestViews);
121
211
  }
122
212
  const EMPTY_FRIENDS = [];
123
213
  const EMPTY_PARTY = [];
@@ -126,8 +216,27 @@ const EMPTY_CHAT = [];
126
216
  const EMPTY_FRIEND_REQUESTS = [];
127
217
  const EMPTY_PARTY_INVITES = [];
128
218
  const OFFLINE_PRESENCE = { online: false };
219
+ /** Friends rows are rebuilt per call too, so compare the friend and their presence rather than the row object. */
220
+ function sameFriendViews(previous, next) {
221
+ if (previous.length !== next.length)
222
+ return false;
223
+ for (let index = 0; index < previous.length; index += 1) {
224
+ const before = previous[index];
225
+ const after = next[index];
226
+ if (before === after)
227
+ continue;
228
+ if (before.userId !== after.userId || before.online !== after.online)
229
+ return false;
230
+ }
231
+ return true;
232
+ }
233
+ /**
234
+ * The local player's friends with their presence: the list keeps one identity until a request is
235
+ * accepted, a friend is removed or a friend's presence flips, so a friends HUD no longer re-renders on
236
+ * every pose write (#2216).
237
+ */
129
238
  export function useFriends() {
130
- return useGameStore((ctx) => ctx.game.social?.friends.list(ctx.player.userId) ?? EMPTY_FRIENDS);
239
+ return useGameStore((ctx) => ctx.game.social?.friends.list(ctx.player.userId) ?? EMPTY_FRIENDS, sameFriendViews);
131
240
  }
132
241
  export function useParty() {
133
242
  return useGameStore((ctx) => ctx.game.social?.party.list(ctx.player.userId) ?? EMPTY_PARTY);
package/dist/index.d.ts CHANGED
@@ -58,6 +58,7 @@ export * from "./interactionPrompt.js";
58
58
  export * from "./startScreen.js";
59
59
  export * from "./menuNav.js";
60
60
  export * from "./menuScreen.js";
61
+ export * from "./canvasCover.js";
61
62
  export * from "./titleMenu.js";
62
63
  export * from "./loadingScreen.js";
63
64
  export * from "./creditsPanel.js";
package/dist/index.js CHANGED
@@ -58,6 +58,7 @@ export * from "./interactionPrompt.js";
58
58
  export * from "./startScreen.js";
59
59
  export * from "./menuNav.js";
60
60
  export * from "./menuScreen.js";
61
+ export * from "./canvasCover.js";
61
62
  export * from "./titleMenu.js";
62
63
  export * from "./loadingScreen.js";
63
64
  export * from "./creditsPanel.js";
@@ -11,6 +11,12 @@ export interface MenuScreenProps {
11
11
  transition?: MenuScreenTransition;
12
12
  /** Dim/blur treatment over the scene behind. Default `"dim"`. */
13
13
  scrim?: MenuScreenScrim;
14
+ /**
15
+ * The screen paints over the whole canvas, so the engine can stop drawing the world behind it.
16
+ * Leave false for a scrim over a live scene or a side panel: anything the scene shows through
17
+ * must keep drawing. Default false.
18
+ */
19
+ coversCanvas?: boolean;
14
20
  /** Enter duration in ms. Default 220. */
15
21
  enterMs?: number;
16
22
  /** Exit duration in ms. Default 160. */
@@ -40,4 +46,4 @@ export interface MenuScreenProps {
40
46
  *
41
47
  * @capability menu-screen animated enter/exit menu screen over a live scene, with translucent scrim, backdrop slot, reduced-motion support and exit unmount
42
48
  */
43
- export declare function MenuScreen({ open, transition, scrim, enterMs, exitMs, reducedMotion, label, role, backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode;
49
+ export declare function MenuScreen({ open, transition, scrim, coversCanvas, enterMs, exitMs, reducedMotion, label, role, backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useSyncExternalStore } from "react";
3
3
  import { createMenuTransition, menuTransitionDurations } from "@jgengine/core/ui/menuTransition";
4
4
  import { useReducedMotion } from "./accessibility.js";
5
+ import { useCanvasCover } from "./canvasCover.js";
5
6
  const KEYFRAMES = {
6
7
  fade: { enter: "jg-fade-in", exit: "jg-fade-out" },
7
8
  slide: { enter: "jg-slide-up", exit: "jg-fade-out" },
@@ -28,9 +29,10 @@ function now() {
28
29
  *
29
30
  * @capability menu-screen animated enter/exit menu screen over a live scene, with translucent scrim, backdrop slot, reduced-motion support and exit unmount
30
31
  */
31
- export function MenuScreen({ open = true, transition = "fade", scrim = "dim", enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }) {
32
+ export function MenuScreen({ open = true, transition = "fade", scrim = "dim", coversCanvas = false, enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }) {
32
33
  const prefersReduced = useReducedMotion();
33
34
  const motion = reducedMotion === undefined ? prefersReduced : reducedMotion || prefersReduced;
35
+ useCanvasCover(open && coversCanvas);
34
36
  const machine = useMemo(() => {
35
37
  const config = { enterMs, exitMs, reducedMotion: motion };
36
38
  const initial = open
@@ -39,6 +39,8 @@ export interface TitleMenuProps {
39
39
  transition?: MenuScreenTransition;
40
40
  /** Dim/blur treatment over the scene behind. Default `"dim"`. */
41
41
  scrim?: MenuScreenScrim;
42
+ /** The title paints over the whole canvas, so the engine may stop drawing the world behind it. Default false. */
43
+ coversCanvas?: boolean;
42
44
  enterMs?: number;
43
45
  exitMs?: number;
44
46
  reducedMotion?: boolean;
@@ -57,4 +59,4 @@ export interface TitleMenuProps {
57
59
  *
58
60
  * @capability title-menu title-screen block — wordmark, tagline, navigable verb list, footer, backdrop slot, animated over a live scene
59
61
  */
60
- export declare function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open, transition, scrim, enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode;
62
+ export declare function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open, transition, scrim, coversCanvas, enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode;
package/dist/titleMenu.js CHANGED
@@ -25,7 +25,7 @@ const itemButton = {
25
25
  *
26
26
  * @capability title-menu title-screen block — wordmark, tagline, navigable verb list, footer, backdrop slot, animated over a live scene
27
27
  */
28
- export function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }) {
28
+ export function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", coversCanvas, enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }) {
29
29
  const disabled = items.filter((item) => item.disabled === true).map((item) => item.id);
30
30
  const nav = useMenuNav({
31
31
  ids: items.map((item) => item.id),
@@ -34,7 +34,7 @@ export function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, fo
34
34
  onConfirm: onSelect,
35
35
  ...(onBack === undefined ? {} : { onBack }),
36
36
  });
37
- return (_jsx(MenuScreen, { open: open, transition: transition, scrim: scrim, ...(enterMs === undefined ? {} : { enterMs }), ...(exitMs === undefined ? {} : { exitMs }), ...(reducedMotion === undefined ? {} : { reducedMotion }), ...(onClosed === undefined ? {} : { onClosed }), label: "Title menu", ...(backdrop === undefined ? {} : { backdrop }), className: className, style: style, contentClassName: contentClassName, contentStyle: contentStyle, children: _jsxs("div", { "data-title-menu": "", style: column, ...nav.containerProps, children: [eyebrow === undefined ? null : (_jsx("p", { "data-title-eyebrow": "", style: {
37
+ return (_jsx(MenuScreen, { open: open, transition: transition, scrim: scrim, ...(coversCanvas === undefined ? {} : { coversCanvas }), ...(enterMs === undefined ? {} : { enterMs }), ...(exitMs === undefined ? {} : { exitMs }), ...(reducedMotion === undefined ? {} : { reducedMotion }), ...(onClosed === undefined ? {} : { onClosed }), label: "Title menu", ...(backdrop === undefined ? {} : { backdrop }), className: className, style: style, contentClassName: contentClassName, contentStyle: contentStyle, children: _jsxs("div", { "data-title-menu": "", style: column, ...nav.containerProps, children: [eyebrow === undefined ? null : (_jsx("p", { "data-title-eyebrow": "", style: {
38
38
  margin: 0,
39
39
  fontSize: hudType("xs"),
40
40
  letterSpacing: "0.24em",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "styles": "bun scripts/styles.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.32.0"
38
+ "@jgengine/core": "^0.34.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^19.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.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.34.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.34.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -186,16 +186,18 @@
186
186
  - `SettingCategoryDef` (interface): interface SettingCategoryDef — Declares or relabels/reorders a category tab; use it for a custom category or to reshape the built-ins.
187
187
  - `SettingKind` (type): type SettingKind = "slider" | "toggle" | "select" — ⚠ undocumented
188
188
  - `SettingOption` (interface): interface SettingOption { value: string; label: string } — ⚠ undocumented · used by `COLORBLIND_OPTIONS` (@jgengine/core/settings/accessibilitySettings): Colorblind palette choices.
189
- - `SettingValue` (type): type SettingValue = number | boolean | string — ⚠ undocumented · used by `useSetting` (@jgengine/react): Read + write one persisted setting; re-renders when the value changes anywhere.
189
+ - `SettingValue` (type): type SettingValue = number | boolean | string — ⚠ undocumented · used by `asSettingType`: Read a stored value as the type the caller asked for.
190
190
  - `SettingsActionDef` (interface): interface SettingsActionDef — A game-state action (Restart, Quit to menu, …) shown as rows in the first "Game" settings tab — never a floating button or a rebindable key.
191
191
  - `SettingsStore` (interface): interface SettingsStore { get<T extends SettingValue>(id: string, fallback: T): T; set(id: string, value: SettingValue): void; subscribe(listener: () => void): () => void } — ⚠ undocumented · used by `createSettingsStore`: Reactive, localStorage-backed settings store shared by the shell wiring and React hooks.
192
192
  - `SettingsSurface` (type): type SettingsSurface = "quick" — `quick` shows compact on-screen volume/graphics buttons; `false` (default) mounts no engine trigger — open the menu from your own UI with `<SettingsTrigger>` or `useSettings().open()`.
193
+ - `SettingsTypeError` (class): class SettingsTypeError extends Error — A stored setting holds a value the read asked for as another type, so no honest value exists.
193
194
  - `SettingsVariant` (type): type SettingsVariant = "panel" | "sheet" | "sidebar" | "fullscreen" — The four themed settings layouts, chosen with `defineGame({ settings: { variant } })`. All read the game's `--jg-*` theme tokens.
194
195
  - `UI_SCALE_MAX` (const): const UI_SCALE_MAX: 1.5 — ⚠ undocumented
195
196
  - `UI_SCALE_MIN` (const): const UI_SCALE_MIN: 0.5 — ⚠ undocumented
197
+ - `asSettingType` (function): function asSettingType<T extends SettingValue>(id: string, stored: unknown, fallback: T): T — Read a stored value as the type the caller asked for. A value of another primitive type converts when the result is the same value in the asked-for type (a number as its digits, a boolean as `"true"`/`"false"`); otherwise it throws {@link SettingsTypeError}, naming the key and both types, instead of handing back the fallback as if nothing were stored.
196
198
  - `busVolumeSettingId` (function): function busVolumeSettingId(busId: string): string — ⚠ undocumented
197
199
  - `createSettingsStore` (function): function createSettingsStore(storage: Pick<WebStorageLike, "getItem" | "setItem"> | null | undefined = defaultStorage()): SettingsStore — Reactive, localStorage-backed settings store shared by the shell wiring and React hooks.
198
- - `loadSettingValue` (function): function loadSettingValue<T extends SettingValue>(id: string, fallback: T, storage: Pick<WebStorageLike, "getItem"> | null | undefined = defaultStorage()): T — ⚠ undocumented
200
+ - `loadSettingValue` (function): function loadSettingValue<T extends SettingValue>(id: string, fallback: T, storage: Pick<WebStorageLike, "getItem"> | null | undefined = defaultStorage()): T — Read one persisted setting as `fallback`'s type. An absent or unreadable cell yields the fallback; a stored value of an incompatible type throws {@link SettingsTypeError}.
199
201
  - `saveSettingValue` (function): function saveSettingValue(id: string, value: SettingValue, storage: Pick<WebStorageLike, "setItem"> | null | undefined = defaultStorage()): void — ⚠ undocumented
200
202
  - `settingStorageKey` (function): function settingStorageKey(id: string): string — ⚠ undocumented
201
203
 
@@ -310,7 +312,7 @@
310
312
  - `SettingCategoryDef` (interface): interface SettingCategoryDef — Declares or relabels/reorders a category tab; use it for a custom category or to reshape the built-ins.
311
313
  - `SettingKind` (type): type SettingKind = "slider" | "toggle" | "select" — ⚠ undocumented
312
314
  - `SettingOption` (interface): interface SettingOption { value: string; label: string } — ⚠ undocumented · used by `COLORBLIND_OPTIONS` (@jgengine/core/settings/accessibilitySettings): Colorblind palette choices.
313
- - `SettingValue` (type): type SettingValue = number | boolean | string — ⚠ undocumented · used by `useSetting` (@jgengine/react): Read + write one persisted setting; re-renders when the value changes anywhere.
315
+ - `SettingValue` (type): type SettingValue = number | boolean | string — ⚠ undocumented · used by `asSettingType` (@jgengine/core/settings/settingsModel): Read a stored value as the type the caller asked for.
314
316
  - `SettingsActionDef` (interface): interface SettingsActionDef — A game-state action (Restart, Quit to menu, …) shown as rows in the first "Game" settings tab — never a floating button or a rebindable key.
315
317
  - `SettingsStore` (interface): interface SettingsStore { get<T extends SettingValue>(id: string, fallback: T): T; set(id: string, value: SettingValue): void; subscribe(listener: () => void): () => void } — ⚠ undocumented · used by `createSettingsStore`: Reactive, localStorage-backed settings store shared by the shell wiring and React hooks.
316
318
  - `SettingsSurface` (type): type SettingsSurface = "quick" — `quick` shows compact on-screen volume/graphics buttons; `false` (default) mounts no engine trigger — open the menu from your own UI with `<SettingsTrigger>` or `useSettings().open()`.
@@ -682,6 +684,7 @@
682
684
  - `CameraShakeMeter` (function): function CameraShakeMeter({ controller, animate = false, title = "CAMERA SHAKE", kindLabels, variation = "glass", className, style, }: CameraShakeMeterProps): ReactNode — A drop-in HUD panel that visualizes a core {@link CameraShakeController}: a trauma meter (reusing the shared {@link StaminaBar} building block) plus a live percentage and the current impact `kind` label. Presentation only — it reads the controller and never interprets `kind`, mapping it through `kindLabels` for display. A shell component applies the same controller's `offset()` to the camera; this panel is the on-screen readout of how much shake is in flight.
683
685
  - `CameraShakeMeterProps` (interface): interface CameraShakeMeterProps — Props for {@link CameraShakeMeter}.
684
686
  - `CameraShakeReadout` (interface): interface CameraShakeReadout — A live read of a camera-shake controller: current trauma `0..1` and the last game-owned `kind`.
687
+ - `CanvasCover` (interface): interface CanvasCover — A page-wide declaration that some surface paints over the whole game canvas.
685
688
  - `CaptureOdds` (function): function CaptureOdds({ chance, className, fillClassName, }: { chance: number; className?: string; fillClassName?: string; }): React.JSX.Element — ⚠ undocumented
686
689
  - `CardFace` (function): function CardFace({ rank, suit, faceDown = false, width = 64, height = 90, className, style, children, }: { rank: Rank; suit: Suit; faceDown?: boolean; width?: number; height?: number; className?: string; style?: CSSProperties; children?: ReactNode; }): React.JSX.Element — A single playing-card face: rank + suit pips, or a patterned back when `faceDown`.
687
690
  - `CardStack` (function): function CardStack({ pile, zone, cardOf, ...rest }: { pile: CardPileState; zone: string; cardOf: (id: string) => PlayingCard; } & Omit<Parameters<typeof StackedPile>[0], "cards">): React.JSX.Element — `StackedPile` bound to a headless `CardPileState` zone: reads the ordered card ids from `pile.zones[zone]` and resolves each to a `PlayingCard` via `cardOf`.
@@ -866,7 +869,7 @@
866
869
  - `MenuNavController` (interface): interface MenuNavController — The live navigation controller returned by {@link useMenuNav}.
867
870
  - `MenuNavItemProps` (interface): interface MenuNavItemProps — Props a menu item needs so {@link useMenuNav} can focus it and track pointer/focus.
868
871
  - `MenuNavRender` (type): type MenuNavRender = (nav: MenuNavController) => ReactNode — Render prop for a menu whose items come from {@link useMenuNav}.
869
- - `MenuScreen` (function): function MenuScreen({ open = true, transition = "fade", scrim = "dim", enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode — One animated screen of a game front-end: full-bleed, laid out by the game, with an enter/exit animation that runs on transform/opacity only (no layout, no frame hitch) and unmounts when the exit finishes. The scrim is translucent and the optional `backdrop` slot paints behind the content, so a live 3D scene stays visible behind a title, pause or settings screen. Durations come from the caller or collapse to zero under reduced motion.
872
+ - `MenuScreen` (function): function MenuScreen({ open = true, transition = "fade", scrim = "dim", coversCanvas = false, enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode — One animated screen of a game front-end: full-bleed, laid out by the game, with an enter/exit animation that runs on transform/opacity only (no layout, no frame hitch) and unmounts when the exit finishes. The scrim is translucent and the optional `backdrop` slot paints behind the content, so a live 3D scene stays visible behind a title, pause or settings screen. Durations come from the caller or collapse to zero under reduced motion.
870
873
  - `MenuScreenProps` (interface): interface MenuScreenProps — Props for {@link MenuScreen}.
871
874
  - `MenuScreenScrim` (type): type MenuScreenScrim = "none" | "dim" | "blur" — How much a {@link MenuScreen} dims the scene behind it. `none` keeps a live 3D scene fully visible.
872
875
  - `MenuScreenTransition` (type): type MenuScreenTransition = "fade" | "slide" | "scale" | "none" — Which motion a {@link MenuScreen} uses when it enters and leaves.
@@ -994,7 +997,7 @@
994
997
  - `TimerReadoutSource` (type): type TimerReadoutSource = "auto" | "remaining" | "elapsed" — Which value a readout leads with. `"auto"` follows the timer's direction — remaining for a countdown, elapsed for a countup charge.
995
998
  - `TimerRing` (function): function TimerRing({ timer, id, size = 72, thickness = 8, mode = "fill", color = "var(--jg-accent, #38bdf8)", trackColor = "var(--jg-bar-track, rgba(148,163,184,0.25))", children, active = true, className, style, }: TimerRingProps): ReactNode — A radial charge/cooldown ring driven off a {@link TimerSet}'s `progress01`. Re-reads every frame and sweeps an SVG arc as the timer advances — ability charge, cooldown wipe, respawn ring, capture progress, all the same widget. `mode` chooses filling vs. draining; center `children` can hold a readout. HudTheme-skinned via `--jg-accent` / `--jg-bar-track`.
996
999
  - `TimerRingProps` (interface): interface TimerRingProps — Props for {@link TimerRing}.
997
- - `TitleMenu` (function): function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode — A game front-end title screen: wordmark, tagline, the game's verbs in a keyboard/gamepad-navigable list, a footer and an optional backdrop. The engine owns navigation, focus and the enter/exit animation; the game owns the title markup, wording, verbs, art and every colour through HudTheme tokens. Pass the live scene (or artwork) as `backdrop` so the title renders over 3D instead of a blank page.
1000
+ - `TitleMenu` (function): function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", coversCanvas, enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode — A game front-end title screen: wordmark, tagline, the game's verbs in a keyboard/gamepad-navigable list, a footer and an optional backdrop. The engine owns navigation, focus and the enter/exit animation; the game owns the title markup, wording, verbs, art and every colour through HudTheme tokens. Pass the live scene (or artwork) as `backdrop` so the title renders over 3D instead of a blank page.
998
1001
  - `TitleMenuItem` (interface): interface TitleMenuItem — One verb on a {@link TitleMenu}: New Game, Continue, Settings, Credits, Quit.
999
1002
  - `TitleMenuProps` (interface): interface TitleMenuProps — Props for {@link TitleMenu}.
1000
1003
  - `ToastStack` (function): function ToastStack({ action, limit = 4, className, renderToast, }: { action: string; limit?: number; className?: string; renderToast?: (entry: FeedEntry, index: number) => ReactNode; }): React.JSX.Element | null — Render `ctx.game.feed`'s entries for `action` as a newest-first toast stack — the feed-backed sibling of `@jgengine/core/game/toasts`' `createToastQueue`. Reach for this when the message source is already an engine event bound onto `ctx.game.feed` (kill feed, quest updates, loot log); reach for a `createToastQueue` when the game raises ad-hoc messages that need their own TTL independent of the feed's ring buffer.
@@ -1033,6 +1036,7 @@
1033
1036
  - `actionFromAbilitySlot` (function): function actionFromAbilitySlot(slot: AbilitySlotSnapshot, extra?: Partial<ActionDef> & { resourceId?: string }): ActionDef — Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) — the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome` is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
1034
1037
  - `barTokens` (function): function barTokens(tokens: BarTokens): CSSProperties — A style object binding vitals tokens to CSS custom properties — spread onto any HUD ancestor to restyle every atomic bar under it at once. Only the keys you pass are set; the rest fall back to {@link DEFAULT_BAR_TOKENS} inside the bars. This is the single global-theming seam.
1035
1038
  - `betterAuthIdentity` (function): function betterAuthIdentity(state: BetterAuthSessionState, options?: { signOut?: () => void }): IdentitySource — ⚠ undocumented
1039
+ - `canvasCover` (const): const canvasCover: CanvasCover — The page's canvas cover. A full-screen menu (or a game's own opaque overlay) holds it; the shell stops drawing the world, its shadow passes and postfx while it is held, and draws again from the untouched scene when it lifts. A translucent scrim or a side panel must not hold it — anything the scene shows through has to keep drawing.
1036
1040
  - `chatTransportFromSync` (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders.
1037
1041
  - `clerkIdentity` (function): function clerkIdentity(state: ClerkUserState, options?: { signOut?: () => void }): IdentitySource — ⚠ undocumented
1038
1042
  - `createHeldKeyTracker` (function): function createHeldKeyTracker(target: HeldKeyEventTarget): { isDown: (code: string) => boolean; dispose: () => void; } — ⚠ undocumented
@@ -1077,6 +1081,8 @@
1077
1081
  - `useAxisChannel` (function): function useAxisChannel(config: AxisChannelConfig): UseAxisChannelResult — Wires useHeldKeys into a fresh AxisChannel, ready for a per-frame `channel.sample(dt, isDown)`. The channel is recreated when `config` identity changes, so pass a stable config (useMemo/module constant at the call site) unless a rebind is intended.
1078
1082
  - `useBrowserSuspension` (function): function useBrowserSuspension(onSuspend: (reason: BrowserSuspensionReason) => void, options: BrowserSuspensionOptions = {}): void — Observe browser interruptions while mounted, invoking the latest callback without replacing listeners.
1079
1083
  - `useCameraShake` (function): function useCameraShake(controller: CameraShakeController, animate = true): CameraShakeReadout — Subscribe to a camera-shake controller and re-render on every change (add / decay / clear). Optionally drives `update()` on an animation frame so the trauma meter bleeds down live even when no game loop ticks the controller. Returns the current trauma + kind for a HUD readout.
1084
+ - `useCanvasCover` (function): function useCanvasCover(declared: boolean): void — Declare the canvas fully covered while `declared` is true, and release on unmount.
1085
+ - `useCanvasCovered` (function): function useCanvasCovered(): boolean — True while a surface covers the canvas.
1080
1086
  - `useChat` (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[] — ⚠ undocumented
1081
1087
  - `useChatBubbles` (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[] — ⚠ undocumented
1082
1088
  - `useCoachMarks` (function): function useCoachMarks(sequence: CoachMarkSequence): CoachMarkView | null — Subscribe to a coach-mark sequence and re-render on every change, returning the current step view (or `null` when the tour is complete or waiting on a gate).
@@ -1094,10 +1100,10 @@
1094
1100
  - `useEventMeter` (function): function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView — Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
1095
1101
  - `useEventTicker` (function): function useEventTicker(ticker: EventTicker, animate = true): EventTickerView[] — Subscribe to an event ticker and re-render it on an animation frame (unless the game already ticks it), returning the live entries newest-first with `fade` applied. Driving the frame loop is what makes entries visibly fade and drop as they age out.
1096
1102
  - `useFastTravel` (function): function useFastTravel<TMeta = unknown>(network: FastTravelNetwork<TMeta>): FastTravelNetwork<TMeta> — Subscribe a component to a fast-travel network's discovery changes. Returns the network unchanged; the hook only forces a re-render when a point is discovered or the network is restored, so callers can read `destinations`/`list` in render.
1097
- - `useFeed` (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[] — ⚠ undocumented
1103
+ - `useFeed` (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[] — Recent feed entries for `action`, newest last: the list keeps one identity until an entry is pushed, so a HUD ticker or kill feed no longer re-renders on every pose write (#2216).
1098
1104
  - `useFog` (function): function useFog(fog: FogField): ReturnType<FogField["cells"]> — ⚠ undocumented
1099
1105
  - `useFriendRequests` (function): function useFriendRequests(): FriendRequestEntry[] — ⚠ undocumented
1100
- - `useFriends` (function): function useFriends(): FriendEntry[] — ⚠ undocumented
1106
+ - `useFriends` (function): function useFriends(): FriendEntry[] — The local player's friends with their presence: the list keeps one identity until a request is accepted, a friend is removed or a friend's presence flips, so a friends HUD no longer re-renders on every pose write (#2216).
1101
1107
  - `useGame` (function): function useGame(): { commands: Omit<GameContext["game"]["commands"], "run"> & { run(name: string, input: unknown): DispatchedCommandResult }; events: GameEvents } — UI command surface. Hosted commands return a promise; await it to display acknowledgement or rejection. Registration and runAs remain trusted local APIs.
1102
1108
  - `useGameClock` (function): function useGameClock(): ClockSnapshot & { controls: SimClock } — ⚠ undocumented
1103
1109
  - `useGameContext` (function): function useGameContext(): GameContext — ⚠ undocumented
@@ -1140,7 +1146,7 @@
1140
1146
  - `usePlural` (function): function usePlural(): (key: string, count: number, params?: TParams) => string — Return the plural-aware translator bound to the current locale.
1141
1147
  - `usePrefersReducedMotion` (function): function usePrefersReducedMotion(): boolean — OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live boolean — SSR-safe (defaults false). Use it to seed an accessibility store's `reducedMotion` default.
1142
1148
  - `usePresence` (function): function usePresence(userId: string): PresenceInfo — ⚠ undocumented
1143
- - `useQuestJournal` (function): function useQuestJournal(): QuestInstance[] — ⚠ undocumented
1149
+ - `useQuestJournal` (function): function useQuestJournal(): QuestInstance[] — The local player's journal: the list keeps one identity while the accepted quests, their status and their objective progress are unchanged, so a tracker HUD no longer re-renders on every pose write (#2216). Accepting, progressing, completing or revoking a quest re-renders once.
1144
1150
  - `useRafLoop` (function): function useRafLoop(onFrame: (deltaSeconds: number) => void, active = true): void — Run a requestAnimationFrame loop while `active`, calling `onFrame(deltaSeconds)` each frame. The callback is kept in a ref so re-renders never restart the loop; cleanup cancels the pending frame. For scene-graph work inside a canvas prefer R3F's useFrame — this is for DOM-side animation outside the renderer.
1145
1151
  - `useRebindSession` (function): function useRebindSession(session: RebindSession): RebindSession — Subscribe to a rebind session and re-render on every change (capture, reset, arm/disarm, restore). Returns the session unchanged for convenient inline use.
1146
1152
  - `useReducedMotion` (function): function useReducedMotion(): boolean — True when motion should be skipped: the OS `prefers-reduced-motion` preference, or the nearest {@link AccessibilityProvider}'s `reducedMotion` setting. Works without a provider.
@@ -1149,10 +1155,10 @@
1149
1155
  - `useRoster` (function): function useRoster(userId?: string): readonly RosterEntry[] — ⚠ undocumented
1150
1156
  - `useSaveSlots` (function): function useSaveSlots(model: SaveSlots): SaveSlotMeta[] — Subscribe to a {@link SaveSlots} index and re-render on any change (write, clear, rename, restore). Returns the live list of slots in menu order. Wire it into a save-select screen and mutate the model in event handlers — the menu re-renders itself.
1151
1157
  - `useSaveStore` (function): function useSaveStore<T>(store: SaveStore<T>, options?: { load?: boolean }): SaveStoreView<T> — Bind a {@link SaveStore} to React: returns the live `value` and `status` and re-renders whenever either changes. By default it calls `load()` once on mount (pass `{ load: false }` to hydrate yourself). The same hook works for offline (localStorage) and cloud (Convex/DB) saves — only the store's backend differs.
1152
- - `useSceneEntities` (function): function useSceneEntities(): readonly SceneEntity[] — ⚠ undocumented
1158
+ - `useSceneEntities` (function): function useSceneEntities(): readonly SceneEntity[] — Live entities: the returned list keeps one identity until an entity spawns, despawns, is replaced or the store is hydrated — a pose write does not re-render its readers (#2212). `entity.list()` allocates a new array per write against an `Object.is` comparison, so every pose write re-rendered every HUD reading this hook. The records themselves are mutated in place, so a reader that needs a pose right now can still take `entity.position`; render-loop work reads it in `useFrame`.
1153
1159
  - `useSceneEntityIds` (function): function useSceneEntityIds(): readonly string[] — Membership-only entity id list: the returned array keeps a stable identity across per-frame pose writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
1154
1160
  - `useSceneObjectIds` (function): function useSceneObjectIds(): readonly string[] — Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove.
1155
- - `useSceneObjects` (function): function useSceneObjects(): readonly SceneObject[] — ⚠ undocumented
1161
+ - `useSceneObjects` (function): function useSceneObjects(): readonly SceneObject[] — Placed objects: the returned list keeps one identity across move and rotate writes, so a HUD reading it no longer re-renders on every pose write (#2212). Place, remove and the non-pose fields (catalog id, parent space, animation, visual, state, slots) still re-render. `move`/`rotate` replace the record, so take live placement from `ctx.scene.object.get(instanceId)` rather than the record's `position`.
1156
1162
  - `useSelectionView` (function): function useSelectionView(members: readonly EntitySummaryDef[], options?: UseSelectionViewOptions): SelectionModel — The DATA/HOOK layer: summarize a selected-entity list into a live model with a focused primary, ordered members, same-kind groups, keyboard focus stepping, and a virtualization window. Rendering-agnostic — feed it to {@link SelectionPanel} or read the `view` directly.
1157
1163
  - `useSequenceDirector` (function): function useSequenceDirector(director: SequenceDirector): SequenceDirectorBinding — Bind a {@link SequenceDirector} to a component: subscribe to its state, and while it is playing run a `requestAnimationFrame` loop that ticks the director each frame off the same injected clock — so a game drops in one hook and the cutscene advances itself. Returns the current playback state (playhead, progress, playing/done) plus bound `play`/`pause`/`skip`/`seek`/`stop`. Cue side effects belong on `director.onCue`, not here — the hook never interprets a cue.
1158
1164
  - `useSession` (function): function useSession(): IdentitySource — ⚠ undocumented
@@ -1171,7 +1177,7 @@
1171
1177
  - `useWaypoints` (function): function useWaypoints(source: WaypointTracker | readonly Waypoint[]): readonly Waypoint[] — Subscribe to a {@link WaypointTracker} and re-render on every change, returning its current waypoints. Pass a plain array instead and it is returned as-is (no subscription), so a game can drive the host from a tracker or from its own derived state.
1172
1178
  - `useWorldBrowser` (function): function useWorldBrowser(options: { fetchSessions: () => Promise<readonly SessionListing[]>; filter?: MatchFilter; limit?: number; refreshMs?: number; }): WorldBrowserState — Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and filters through matchmaking's browseSessions. fetchSessions must be identity-stable (wrap in useCallback at the call site) or every render refetches.
1173
1179
  - `useWorldInvites` (function): function useWorldInvites(): WorldInvite[] — ⚠ undocumented
1174
- - `useWorldItems` (function): function useWorldItems(): readonly WorldItemRecord[] — ⚠ undocumented
1180
+ - `useWorldItems` (function): function useWorldItems(): readonly WorldItemRecord[] — Live dropped-item records: the returned list keeps one identity until an item spawns, is picked up or is removed. `worldItem.list()` allocates a new array per call and the shared store signal fires on every pose write, so a raw read re-rendered every marker each frame (#2175). A marker reads its own live pose imperatively (useFrame).
1175
1181
 
1176
1182
  ## @jgengine/react/accessibility
1177
1183
 
@@ -1249,6 +1255,13 @@
1249
1255
  - `CameraShakeReadout` (interface): interface CameraShakeReadout — A live read of a camera-shake controller: current trauma `0..1` and the last game-owned `kind`.
1250
1256
  - `useCameraShake` (function): function useCameraShake(controller: CameraShakeController, animate = true): CameraShakeReadout — Subscribe to a camera-shake controller and re-render on every change (add / decay / clear). Optionally drives `update()` on an animation frame so the trauma meter bleeds down live even when no game loop ticks the controller. Returns the current trauma + kind for a HUD readout.
1251
1257
 
1258
+ ## @jgengine/react/canvasCover
1259
+
1260
+ - `CanvasCover` (interface): interface CanvasCover — A page-wide declaration that some surface paints over the whole game canvas.
1261
+ - `canvasCover` (const): const canvasCover: CanvasCover — The page's canvas cover. A full-screen menu (or a game's own opaque overlay) holds it; the shell stops drawing the world, its shadow passes and postfx while it is held, and draws again from the untouched scene when it lifts. A translucent scrim or a side panel must not hold it — anything the scene shows through has to keep drawing.
1262
+ - `useCanvasCover` (function): function useCanvasCover(declared: boolean): void — Declare the canvas fully covered while `declared` is true, and release on unmount.
1263
+ - `useCanvasCovered` (function): function useCanvasCovered(): boolean — True while a surface covers the canvas.
1264
+
1252
1265
  ## @jgengine/react/cards
1253
1266
 
1254
1267
  - `CardFace` (function): function CardFace({ rank, suit, faceDown = false, width = 64, height = 90, className, style, children, }: { rank: Rank; suit: Suit; faceDown?: boolean; width?: number; height?: number; className?: string; style?: CSSProperties; children?: ReactNode; }): React.JSX.Element — A single playing-card face: rank + suit pips, or a patterned back when `faceDown`.
@@ -1473,9 +1486,9 @@
1473
1486
  - `useDomEvent` (function): function useDomEvent<E>(resolveTarget: () => DomEventTarget | null, type: string, handler: (event: E) => void, options?: { capture?: boolean; passive?: boolean }): void — Attach a DOM event listener with automatic cleanup. `resolveTarget` runs inside the effect, so `() => window` and ref-reading resolvers are SSR-safe; return null to skip attaching. The handler is kept in a ref, so a fresh closure per render never re-binds the listener. Re-binds only when `type` or `options.capture`/`options.passive` change.
1474
1487
  - `useEntityStat` (function): function useEntityStat(instanceId: string, statId: string): StatValue | null — ⚠ undocumented
1475
1488
  - `useEventMeter` (function): function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView — Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
1476
- - `useFeed` (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[] — ⚠ undocumented
1489
+ - `useFeed` (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[] — Recent feed entries for `action`, newest last: the list keeps one identity until an entry is pushed, so a HUD ticker or kill feed no longer re-renders on every pose write (#2216).
1477
1490
  - `useFriendRequests` (function): function useFriendRequests(): FriendRequestEntry[] — ⚠ undocumented
1478
- - `useFriends` (function): function useFriends(): FriendEntry[] — ⚠ undocumented
1491
+ - `useFriends` (function): function useFriends(): FriendEntry[] — The local player's friends with their presence: the list keeps one identity until a request is accepted, a friend is removed or a friend's presence flips, so a friends HUD no longer re-renders on every pose write (#2216).
1479
1492
  - `useGame` (function): function useGame(): { commands: Omit<GameContext["game"]["commands"], "run"> & { run(name: string, input: unknown): DispatchedCommandResult }; events: GameEvents } — UI command surface. Hosted commands return a promise; await it to display acknowledgement or rejection. Registration and runAs remain trusted local APIs.
1480
1493
  - `useGameClock` (function): function useGameClock(): ClockSnapshot & { controls: SimClock } — ⚠ undocumented
1481
1494
  - `useGamePhase` (function): function useGamePhase(): { phase: GamePhase; setPhase: (phase: GamePhase) => void } — Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it.
@@ -1493,18 +1506,18 @@
1493
1506
  - `usePartyInvites` (function): function usePartyInvites(): PartyInviteEntry[] — ⚠ undocumented
1494
1507
  - `usePlayer` (function): function usePlayer(): { userId: string; isNew: boolean } — ⚠ undocumented
1495
1508
  - `usePresence` (function): function usePresence(userId: string): PresenceInfo — ⚠ undocumented
1496
- - `useQuestJournal` (function): function useQuestJournal(): QuestInstance[] — ⚠ undocumented
1509
+ - `useQuestJournal` (function): function useQuestJournal(): QuestInstance[] — The local player's journal: the list keeps one identity while the accepted quests, their status and their objective progress are unchanged, so a tracker HUD no longer re-renders on every pose write (#2216). Accepting, progressing, completing or revoking a quest re-renders once.
1497
1510
  - `useRafLoop` (function): function useRafLoop(onFrame: (deltaSeconds: number) => void, active = true): void — Run a requestAnimationFrame loop while `active`, calling `onFrame(deltaSeconds)` each frame. The callback is kept in a ref so re-renders never restart the loop; cleanup cancels the pending frame. For scene-graph work inside a canvas prefer R3F's useFrame — this is for DOM-side animation outside the renderer.
1498
1511
  - `useRoster` (function): function useRoster(userId?: string): readonly RosterEntry[] — ⚠ undocumented
1499
- - `useSceneEntities` (function): function useSceneEntities(): readonly SceneEntity[] — ⚠ undocumented
1512
+ - `useSceneEntities` (function): function useSceneEntities(): readonly SceneEntity[] — Live entities: the returned list keeps one identity until an entity spawns, despawns, is replaced or the store is hydrated — a pose write does not re-render its readers (#2212). `entity.list()` allocates a new array per write against an `Object.is` comparison, so every pose write re-rendered every HUD reading this hook. The records themselves are mutated in place, so a reader that needs a pose right now can still take `entity.position`; render-loop work reads it in `useFrame`.
1500
1513
  - `useSceneEntityIds` (function): function useSceneEntityIds(): readonly string[] — Membership-only entity id list: the returned array keeps a stable identity across per-frame pose writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
1501
1514
  - `useSceneObjectIds` (function): function useSceneObjectIds(): readonly string[] — Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove.
1502
- - `useSceneObjects` (function): function useSceneObjects(): readonly SceneObject[] — ⚠ undocumented
1515
+ - `useSceneObjects` (function): function useSceneObjects(): readonly SceneObject[] — Placed objects: the returned list keeps one identity across move and rotate writes, so a HUD reading it no longer re-renders on every pose write (#2212). Place, remove and the non-pose fields (catalog id, parent space, animation, visual, state, slots) still re-render. `move`/`rotate` replace the record, so take live placement from `ctx.scene.object.get(instanceId)` rather than the record's `position`.
1503
1516
  - `useTarget` (function): function useTarget(fromInstanceId: string): string | null — The entity `fromInstanceId` currently targets (set by `ctx.scene.entity.setTarget` or `cycleTarget`), for a target frame.
1504
1517
  - `useTicker` (function): function useTicker(hz = 10): number — Re-render at a steady rate. Returns a monotonically increasing tick count driven by a setInterval, for HUD elements that display wall-clock-derived values (cooldowns, cast bars, swing timers) without an engine subscription to hang off. `hz <= 0` disables the ticker.
1505
1518
  - `useWorldBrowser` (function): function useWorldBrowser(options: { fetchSessions: () => Promise<readonly SessionListing[]>; filter?: MatchFilter; limit?: number; refreshMs?: number; }): WorldBrowserState — Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and filters through matchmaking's browseSessions. fetchSessions must be identity-stable (wrap in useCallback at the call site) or every render refetches.
1506
1519
  - `useWorldInvites` (function): function useWorldInvites(): WorldInvite[] — ⚠ undocumented
1507
- - `useWorldItems` (function): function useWorldItems(): readonly WorldItemRecord[] — ⚠ undocumented
1520
+ - `useWorldItems` (function): function useWorldItems(): readonly WorldItemRecord[] — Live dropped-item records: the returned list keeps one identity until an item spawns, is picked up or is removed. `worldItem.list()` allocates a new array per call and the shared store signal fires on every pose write, so a raw read re-rendered every marker each frame (#2175). A marker reads its own live pose imperatively (useFrame).
1508
1521
 
1509
1522
  ## @jgengine/react/hud
1510
1523
 
@@ -1791,7 +1804,7 @@
1791
1804
 
1792
1805
  ## @jgengine/react/menuScreen
1793
1806
 
1794
- - `MenuScreen` (function): function MenuScreen({ open = true, transition = "fade", scrim = "dim", enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode — One animated screen of a game front-end: full-bleed, laid out by the game, with an enter/exit animation that runs on transform/opacity only (no layout, no frame hitch) and unmounts when the exit finishes. The scrim is translucent and the optional `backdrop` slot paints behind the content, so a live 3D scene stays visible behind a title, pause or settings screen. Durations come from the caller or collapse to zero under reduced motion.
1807
+ - `MenuScreen` (function): function MenuScreen({ open = true, transition = "fade", scrim = "dim", coversCanvas = false, enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode — One animated screen of a game front-end: full-bleed, laid out by the game, with an enter/exit animation that runs on transform/opacity only (no layout, no frame hitch) and unmounts when the exit finishes. The scrim is translucent and the optional `backdrop` slot paints behind the content, so a live 3D scene stays visible behind a title, pause or settings screen. Durations come from the caller or collapse to zero under reduced motion.
1795
1808
  - `MenuScreenProps` (interface): interface MenuScreenProps — Props for {@link MenuScreen}.
1796
1809
  - `MenuScreenScrim` (type): type MenuScreenScrim = "none" | "dim" | "blur" — How much a {@link MenuScreen} dims the scene behind it. `none` keeps a live 3D scene fully visible.
1797
1810
  - `MenuScreenTransition` (type): type MenuScreenTransition = "fade" | "slide" | "scale" | "none" — Which motion a {@link MenuScreen} uses when it enters and leaves.
@@ -2051,7 +2064,7 @@
2051
2064
 
2052
2065
  ## @jgengine/react/titleMenu
2053
2066
 
2054
- - `TitleMenu` (function): function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode — A game front-end title screen: wordmark, tagline, the game's verbs in a keyboard/gamepad-navigable list, a footer and an optional backdrop. The engine owns navigation, focus and the enter/exit animation; the game owns the title markup, wording, verbs, art and every colour through HudTheme tokens. Pass the live scene (or artwork) as `backdrop` so the title renders over 3D instead of a blank page.
2067
+ - `TitleMenu` (function): function TitleMenu({ title, eyebrow, tagline, items, onSelect, onBack, footer, controlHint, backdrop, children, open = true, transition = "slide", scrim = "dim", coversCanvas, enterMs, exitMs, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }: TitleMenuProps): ReactNode — A game front-end title screen: wordmark, tagline, the game's verbs in a keyboard/gamepad-navigable list, a footer and an optional backdrop. The engine owns navigation, focus and the enter/exit animation; the game owns the title markup, wording, verbs, art and every colour through HudTheme tokens. Pass the live scene (or artwork) as `backdrop` so the title renders over 3D instead of a blank page.
2055
2068
  - `TitleMenuItem` (interface): interface TitleMenuItem — One verb on a {@link TitleMenu}: New Game, Continue, Settings, Credits, Quit.
2056
2069
  - `TitleMenuProps` (interface): interface TitleMenuProps — Props for {@link TitleMenu}.
2057
2070
 
@@ -2178,7 +2191,7 @@
2178
2191
  ## @jgengine/shell/audio/eventFeedback
2179
2192
 
2180
2193
  - `AnyFeedbackBinding` (type): type AnyFeedbackBinding = { [Name in FeedbackEventName]: FeedbackBinding<Name> }[FeedbackEventName] — Any row of the event mapping, whichever event it binds.
2181
- - `DEFAULT_FEEDBACK_BINDINGS` (const): const DEFAULT_FEEDBACK_BINDINGS: readonly AnyFeedbackBinding[] — The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the local player reads as hurt, damage anywhere else as a hit; a wave start has no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
2194
+ - `DEFAULT_FEEDBACK_BINDINGS` (const): const DEFAULT_FEEDBACK_BINDINGS: readonly AnyFeedbackBinding[] — The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the local player reads as hurt, damage anywhere else as a hit; an event that carries a `position` (damage, death) pans and attenuates its cue, and one without still plays centred. A wave start has no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
2182
2195
  - `DEFAULT_FEEDBACK_REACTIONS` (const): const DEFAULT_FEEDBACK_REACTIONS: Readonly<Record<FeedbackCue, FeedbackReaction>> — The default reactions. They are data: a game overrides one through `audio.feedbackCues` (sound) or `createEventFeedback({ reactions })` (haptics, shake, caption), and replaces the mapping through `bindings` without touching the shell.
2183
2196
  - `EventFeedback` (interface): interface EventFeedback — The event-to-reaction seam. One instance per running world.
2184
2197
  - `EventFeedbackOptions` (interface): interface EventFeedbackOptions — Options a game or host may override.
@@ -2197,6 +2210,13 @@
2197
2210
 
2198
2211
  - `installGestureResume` (function): function installGestureResume(engine: Pick<AudioEngine, "resume">, target: Pick<Document, "addEventListener" | "removeEventListener"> | null = typeof document === "undefined" ? null : document): () => void — Resume the audio context on the first real user gesture. Chrome keeps a freshly created `AudioContext` suspended until the page sees a trusted input, so pressing a key, tapping or clicking is the only moment sound may start; the engine then flushes any cue it held. One listener set per game, removed on unmount.
2199
2212
 
2213
+ ## @jgengine/shell/audio/hapticsRoute
2214
+
2215
+ - `HapticsRoute` (interface): interface HapticsRoute — Route haptic pulses to whatever the player can feel them on.
2216
+ - `HapticsRouteOptions` (interface): interface HapticsRouteOptions — The browser reach of {@link createHapticsRoute}; injected so the route runs headless.
2217
+ - `HapticsSink` (type): type HapticsSink = "gamepad" | "vibration" | "none" — Where a pulse actually landed: a gamepad, the device's own motor, or nowhere.
2218
+ - `createHapticsRoute` (function): function createHapticsRoute(pad: (pulse: HapticPulse) => void, options: HapticsRouteOptions = {}): HapticsRoute — One pulse in, whichever motor the player has: a connected gamepad takes it through the haptic channels the gamepad loop already drives, and a phone with no pad falls back to `navigator.vibrate`. Pad presence is cached and refreshed on connection events, so a hit never asks the browser for its pad list. Reduced motion does not silence this: see `eventFeedback`.
2219
+
2200
2220
  ## @jgengine/shell/audio/loopParams
2201
2221
 
2202
2222
  - `MAX_LOOP_CUTOFF` (const): const MAX_LOOP_CUTOFF: 22050 — Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency.
@@ -2726,8 +2746,9 @@
2726
2746
  ## @jgengine/shell/postfx/postfxOverlay
2727
2747
 
2728
2748
  - `POSTFX_OVERLAY_USERDATA` (const): const POSTFX_OVERLAY_USERDATA: { readonly jgPostfxOverlay: true } — Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect — additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
2729
- - `hidePostfxOverlays` (function): function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void — Hide every currently visible marked object under `scene`, recording what was hidden into `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo exactly that set.
2749
+ - `hidePostfxOverlays` (function): function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[], withUnmodelled = false): void — Hide every currently visible object `scene`'s prepasses must skip, recording what was hidden into `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can undo exactly that set. `withUnmodelled` also hides points and lines, which three's GTAO pass excludes from its normal buffer but a depth prepass keeps.
2730
2750
  - `isPostfxOverlay` (function): function isPostfxOverlay(object: { userData?: Record<string, unknown> }): boolean — True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}.
2751
+ - `releasePostfxOverlayTracking` (function): function releasePostfxOverlayTracking(scene: THREE.Object3D): void — Drop the cached set and its listeners for a scene that is going away, so a torn-down world does not keep its node graph reachable through this module.
2731
2752
  - `restorePostfxOverlays` (function): function restorePostfxOverlays(hidden: THREE.Object3D[]): void — Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list.
2732
2753
 
2733
2754
  ## @jgengine/shell/postfx/stylizePass
@@ -3508,12 +3529,12 @@
3508
3529
 
3509
3530
  ## @jgengine/shell/vision/RevealVision
3510
3531
 
3511
- - `RevealHighlights` (function): function RevealHighlights(props: RevealHighlightsProps): React.JSX.Element | null — Screen-space reveal effect (#115) — highlights tagged entities through occluders (Dark Sight / detective-vision / wallhack style). Renders with `depthTest: false` so the highlight draws over any wall standing between the origin and the revealed entity, rather than the usual depth-sorted scene.
3532
+ - `RevealHighlights` (function): function RevealHighlights(props: RevealHighlightsProps): React.JSX.Element | null — Screen-space reveal effect (#115) — highlights tagged entities through occluders (Dark Sight / detective-vision / wallhack style). Renders with `depthTest: false` so the highlight draws over any wall standing between the origin and the revealed entity, rather than the usual depth-sorted scene. Highlights track the origin and their targets every frame while enabled, so a disabled mode costs no renders.
3512
3533
  - `RevealHighlightsProps` (interface): interface RevealHighlightsProps extends RevealVisionOptions { enabled: boolean; color?: string } — ⚠ undocumented · used by `RevealHighlights`: Screen-space reveal effect (#115) — highlights tagged entities through occluders (Dark Sight / detective-vision / wallhack style).
3513
3534
  - `RevealScreenTint` (function): function RevealScreenTint({ enabled, color = "rgba(56, 189, 248, 0.16)", className }: RevealScreenTintProps): React.JSX.Element | null — Full-screen desaturating tint that reads as "vision mode is on" (Dark Sight / thermal / detective vision).
3514
3535
  - `RevealScreenTintProps` (interface): interface RevealScreenTintProps { enabled: boolean; color?: string; className?: string } — ⚠ undocumented · used by `RevealScreenTint`: Full-screen desaturating tint that reads as "vision mode is on" (Dark Sight / thermal / detective vision).
3515
- - `RevealVisionOptions` (interface): interface RevealVisionOptions { originEntityId?: string; radius: number; tags: readonly string[]; resolveTags: (entity: SceneEntity) => readonly string[] } — ⚠ undocumented · used by `useRevealHits`: Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene.
3516
- - `useRevealHits` (function): function useRevealHits(options: RevealVisionOptions): readonly RevealHit[] — Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene.
3536
+ - `RevealVisionOptions` (interface): interface RevealVisionOptions { originEntityId?: string; radius: number; tags: readonly string[]; resolveTags: (entity: SceneEntity) => readonly string[] } — ⚠ undocumented · used by `useRevealHits`: Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene and refreshed on render.
3537
+ - `useRevealHits` (function): function useRevealHits(options: RevealVisionOptions): readonly RevealHit[] — Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene and refreshed on render.
3517
3538
 
3518
3539
  ## @jgengine/shell/vision/frustumSampleEqual
3519
3540
 
@@ -126,6 +126,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
126
126
 
127
127
  - `isCameraOccluderTransparent` (function) · `import { isCameraOccluderTransparent } from "@jgengine/shell/camera"`
128
128
 
129
+ ## canvas-cover — stop world rendering behind a full-screen surface while input, audio, the HUD and the menu keep running
130
+
131
+ - `canvasCover` (const) · `import { canvasCover } from "@jgengine/react"`
132
+
129
133
  ## caption-feed — bounded, self-expiring, deduped caption lines for the accessibility captions overlay
130
134
 
131
135
  - `createCaptionFeed` (function) · `import { createCaptionFeed } from "@jgengine/shell/audio/captions"`
@@ -347,6 +351,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
347
351
  - `buildGroundCoverageMask` (function) · `import { buildGroundCoverageMask } from "@jgengine/shell/terrain"`
348
352
  - `createGroundEdgeFade` (function) · `import { createGroundEdgeFade } from "@jgengine/shell/terrain"`
349
353
 
354
+ ## haptics-route — send a feedback pulse to the gamepad when one is connected, else to the phone's vibration motor
355
+
356
+ - `createHapticsRoute` (function) · `import { createHapticsRoute } from "@jgengine/shell/audio/hapticsRoute"`
357
+
350
358
  ## heavy-models — models over the per-model triangle budget, for perf probes and smoke assertions
351
359
 
352
360
  - `heavyModels` (function) · `import { heavyModels } from "@jgengine/shell/render/modelLoad"`
@@ -94,6 +94,8 @@ The post chain itself is never hand-wired render passes. Set `defineGame({ postP
94
94
 
95
95
  AO and depth-of-field prepasses reuse the beauty pass's shadow maps; they do not redraw shadow casters. Prepass failure restores shadow update policy and marked overlay visibility. The composer resizes passes through `setSize`, and graph cleanup calls each pass's `dispose` once; avoid reading a pass's internal targets or materials.
96
96
 
97
+ The Graphics tab offers a `postStages` toggle only for a stage the resolved chain can build: an `ao` or `bloom` authored `false`, `aa: "msaa"`/`false` (no SMAA pass), or an unauthored `dof` drops that row, because a toggle over a pass the composer never creates changes nothing. A game with no post chain gets none of the stage rows.
98
+
97
99
  **Orbit camera occlusion:** the third-person orbit rig takes `camera: { collision: { enabled, padding?, minTargetDistance? } }` — a spring-arm that raycasts target→camera each frame and pulls the boom in past walls/terrain so the camera never clips inside geometry. Off by default (unchanged chase feel); enable it for any world with interiors or dense structures.
98
100
 
99
101
  **Camera clearance:** `camera: { firstPerson: { collision } }` keeps the `first` rig's eye out of geometry the body can reach. It is on by default: the boom anchors `armBack` (1.5) behind the eye along the look ray, `clearance` defaults to the near plane plus 0.15, and a blocking surface pulls the camera back toward that anchor so the near plane stays clear. `false` restores the unchecked camera. A game that drives its own camera calls the same seam: `clearCameraBoom({ pivot, desired, query: sceneCameraQuery(ctx.scene.raycast), previousArm, deltaSeconds })` returns the clear `position` and the `arm` to pass back next frame. The query reads `ctx.scene.raycast`, so world solids, scene-object colliders and walls all block it without a new collision layer; entities and terrain stay out so the followed body and the ground cannot pull the boom.