@vgai/engine 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -1,40 +1,35 @@
1
1
  /**
2
2
  * Game root — T7.1 slice 1 ("introduce Game internally") + slice 2 ("world
3
3
  * registry + ordered frame algorithm"), extended in T7.3 slice 1 with real
4
- * `'pixijs'`-kind `WorldInstance` support (`stage`/`physics2d`/`collisions2d`,
5
- * a real `pixiRoot()`) so `world2d/pixi-game-adapter.ts` can register its
6
- * world onto a real `Game` instead of driving its own loop, and in T7.3
7
- * slice 2 with a construction-time stage check (symmetric with the
8
- * threejs/scene check) and the two-world (threejs + pixijs) proof
9
- * (`test/game-three-plus-pixi.test.ts`). T6.2 slice 1 adds real `'react'`-kind
10
- * `WorldInstance` support (`container`, a real `reactRoot()`) the DOM-root
11
- * world surface `runtime/create-runtime.ts`'s roots path now mounts (see
12
- * `docs/REACT-WORLD-DESIGN.md` §1.B); react roots host no `ComponentManager`
13
- * (D8 — components attaching to a react-kind manager already throw,
14
- * `ecs/component-manager.ts`), so `physics`/`collisions`/`camera`/`frame`
15
- * stay `undefined` for them exactly like an opaque/foreign mount.
4
+ * `'canvas'`-kind `RootInstance` support (`stage`/`physics2d`, a real
5
+ * `pixiStage()`) so a canvas adapter can register its world onto a real `Game`
6
+ * instead of driving its own loop, and in T7.3 slice 2 with a
7
+ * construction-time stage check (symmetric with the three/scene check).
8
+ * T6.2 slice 1 adds real `'react'`-kind
9
+ * `RootInstance` support (`container`, a real `reactRoot()`) — the DOM-root
10
+ * world surface `runtime/create-runtime.ts`'s roots path now mounts, with
11
+ * `physics`/`collisions`/`camera`/`frame` `undefined` for them exactly
12
+ * like an opaque/foreign mount.
16
13
  *
17
- * See `docs/GAME-ROOT-DESIGN.md` (D6), particularly §4 (the frame algorithm)
18
- * and §8 stage 2, for the full target shape. This file holds the Game/
19
- * WorldInstance shell, the NEW game-scoped `SystemRunner`, and the
20
- * host-facing `runFrame` frame executor. The public surface is NOT frozen
21
- * yet — do not add these exports to a barrel/index; only the in-scope
22
- * runtime/adapter files import this module directly. The `gpu` per-world
23
- * cache is the one remaining slice-1 omission.
14
+ * (D6), particularly §4 (the frame algorithm) and §8 stage 2, for the full
15
+ * target shape. This file holds the Game/ RootInstance shell, the NEW
16
+ * game-scoped `SystemRunner`, and the host-facing `runFrame` frame executor.
17
+ * The public surface is NOT frozen yet — do not add these exports to a
18
+ * barrel/index; only the in-scope runtime/adapter files import this module
19
+ * directly. The `gpu` per-world cache is the one remaining slice-1 omission.
24
20
  */
25
21
 
26
22
  // TYPE-ONLY pixi import (T7.3 slice 1): `game.ts` must never value-import
27
23
  // `pixi.js` — a value import would pull the Pixi runtime into 3D-only
28
- // bundles that never touch world2d. Every pixi-typed field below is erased
24
+ // bundles that never touch a canvas root. Every pixi-typed field below is erased
29
25
  // at compile time; nothing here constructs or calls into Pixi.
30
26
  import type * as PIXI from 'pixi.js';
31
27
  import type * as THREE from 'three';
32
28
  import type { AdapterSurface as AdapterSurfaceLeaf } from '../adapter/adapter-surface';
33
- import { createInputManagerAdapter } from '../adapter/first-party-systems';
34
- import type { MountedWorld } from '../adapter/game-adapter';
35
29
  import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
30
+ import type { MountedRoot } from '../adapter/root-adapter';
31
+ import type { MountedSetupThreeRoot } from '../adapter/setup-three-root-adapter';
36
32
  import type { SystemAdapters } from '../adapter/system-adapter';
37
- import type { VgaiMountedGame } from '../adapter/vgai-scene-game-adapter';
38
33
  import type { AssetCache } from '../assets';
39
34
  import type { createGameLoop } from '../core/game-loop';
40
35
  import {
@@ -42,19 +37,18 @@ import {
42
37
  DEFAULT_SEEDED_RANDOM_SEED,
43
38
  registerSeededRandom,
44
39
  } from '../core/seeded-random';
40
+ import { createSimClock, registerSimClock, type SimClockInternal } from '../core/sim-clock';
45
41
  import { createSystemRunner, type SystemRunner } from '../core/system-runner';
46
42
  import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from '../core/types';
47
43
  import { createPerformanceProfiler, type PerformanceProfiler } from '../dev/performance-profiler';
48
- import type { ComponentManager } from '../ecs/component-manager';
49
- import type { GameComponent } from '../ecs/game-component';
44
+ import { hasUserData } from '../ecs/user-data';
50
45
  import { InputManager } from '../input/input-manager';
51
46
  import type { CollisionSystem } from '../physics/collision-system';
52
47
  import type { PhysicsRegistry } from '../physics/physics-registry';
48
+ // TYPE-ONLY (same rule as the pixi import above): this lives under `pixi/`,
49
+ // but `game.ts` only ever names its TYPE.
50
+ import type { Physics2DRegistry } from '../pixi/physics-registry';
53
51
  import type { AudioContext as GameAudio } from '../setup/setup-audio';
54
- // TYPE-ONLY (same rule as the pixi import above): these live under
55
- // `world2d/`, but `game.ts` only ever names their TYPES.
56
- import type { Collision2DSystem } from '../world2d/collision-2d';
57
- import type { Physics2DRegistry } from '../world2d/physics2d-registry';
58
52
  import {
59
53
  createDebugRegistry,
60
54
  DebugError,
@@ -63,27 +57,42 @@ import {
63
57
  } from './debug-registry';
64
58
  import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
65
59
  import { createStateBridge, type GameStateBridge } from './state-bridge';
60
+ import type { GameContext, PlaytestContext } from './types';
66
61
 
67
- /** The one loop type — `createGameLoop`'s return shape (D1, fixed-step). */
62
+ /** The one loop type — `createGameLoop`'s return shape (fixed-step sim,
63
+ * display-rate presentation since WO-P13). */
68
64
  export type GameLoop = ReturnType<typeof createGameLoop>;
69
65
 
70
66
  /**
71
- * Game-level play-state control surface (D10, T7.6 `docs/DECISIONS-PENDING.md`
72
- * §D10): pause/resume/step as ONE control surface on the running game, with
73
- * PER-WORLD `pausable` semantics (`WorldInstance.pausable`) a menu/HUD world
74
- * declaring `pausable: false` keeps ticking (input, components, physics) while
75
- * every `pausable: true` world freezes. Interacts with D1 (deliberate
76
- * fixed-rate rendering): a paused-and-pausable world's `render` phase still
77
- * runs every substep (with `dt` forced to `0`, so time-based render effects —
78
- * e.g. a post-processing pass with its own internal clock — don't silently
79
- * keep animating under a "frozen" scene) — simulation freezes, the screen does
80
- * not go black. `GameLoop.timeScale` (unaffected by this) remains the
81
- * orthogonal "speed up/slow down" axis — pausing never touches it, so the
82
- * host's accumulator/rAF loop keeps ticking at its normal cadence and keeps
83
- * calling `runFrame`, which is what makes "paused still renders" possible
84
- * (the previous `GameSession.pause()` implementation set `loop.timeScale = 0`,
85
- * whichper D1 starves the accumulator and stops rendering too; that was
86
- * the bug this control surface replaces, not a compatible behavior to keep).
67
+ * WO-P13 the tail of `PHASE_ORDER` that presents rather than simulates, and
68
+ * therefore runs once per DISPLAY frame (`runRenderFrameImpl`) rather than
69
+ * once per fixed substep. The same two phases `runTicks`' `skipRenderPhases`
70
+ * fast-forward skips and `render-control.ts`'s `renderOnce()` drives one
71
+ * definition of "the render phases" across all three, kept in `PHASE_ORDER`'s
72
+ * own order.
73
+ */
74
+ const DISPLAY_RATE_PHASES: readonly SystemPhaseName[] = PHASE_ORDER.filter(
75
+ (phase) => phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER,
76
+ );
77
+
78
+ /**
79
+ * Game-level play-state control surface (D10, T7.6): pause/resume/step as ONE
80
+ * control surface on the running game, with PER-WORLD `pausable` semantics
81
+ * (`RootInstance.pausable`)a menu/HUD world declaring `pausable: false` keeps
82
+ * ticking (input, physics) while every `pausable: true` world
83
+ * freezes. A paused-and-pausable world's `render` phase still runs (with `dt`
84
+ * forced to `0`, so time-based render effects — e.g. a post-processing pass with
85
+ * its own internal clock — don't silently keep animating under a "frozen" scene)
86
+ * — simulation freezes, the screen does not go black. WO-P13 changed only the
87
+ * CADENCE of that: once per display frame under a host that drives
88
+ * `runRenderFrame`, once per substep under one that still renders inside
89
+ * `runFrame`. `GameLoop.timeScale` (unaffected by this) remains the orthogonal
90
+ * "speed up/slow down" axis — pausing never touches it, so the host's
91
+ * accumulator/rAF loop keeps ticking at its normal cadence, which is what makes
92
+ * "paused still renders" possible (the previous `GameSession.pause()`
93
+ * implementation set `loop.timeScale = 0`, which starves the accumulator — and,
94
+ * before WO-P13, stopped rendering with it; that was the bug this control
95
+ * surface replaces, not a compatible behavior to keep).
87
96
  */
88
97
  export interface PlayState {
89
98
  /** Whether the game is currently paused (game-level — see the per-world
@@ -94,10 +103,9 @@ export interface PlayState {
94
103
  * while already paused is a no-op) and, for every `pausable`, self-driven
95
104
  * (`drivesOwnLoop`) world, invoke its loop-gate capability
96
105
  * (`mounted.setPaused(true)`) — reporting loudly, once per world, when that
97
- * capability is absent (an honest "cannot gate" instead of a silent no-op,
98
- * `docs/CAPABILITY-TIERS.md` §d). Also silences every `pausable` world's
99
- * audio via its `SystemAdapters.audio` (absent ⇒ the same loud, once-only
100
- * report).
106
+ * capability is absent (an honest "cannot gate" instead of a silent no-op).
107
+ * Also silences every `pausable` world's audio via its
108
+ * `SystemAdapters.audio` (absent ⇒ the same loud, once-only report).
101
109
  */
102
110
  pause(): void;
103
111
  /** Resume every `pausable` world — the inverse of `pause()`, same
@@ -122,28 +130,27 @@ export interface PlayState {
122
130
  }
123
131
 
124
132
  /**
125
- * The kinds of render surface a world can be. `'threejs'` and `'pixijs'`
126
- * roots are real as of T7.3 slice 1 (`create-runtime.ts`'s default threejs
127
- * world; `world2d/pixi-game-adapter.ts`'s pixi world); `'react'` is named
133
+ * The kinds of render surface a world can be. `'three'` and `'canvas'`
134
+ * roots are both real; `'react'` is named
128
135
  * here so the type is already shaped for T7.4 and no caller has to widen a
129
136
  * union later.
130
137
  *
131
138
  * Re-exported from `adapter/adapter-surface.ts` (T7.5) — moved there so
132
- * `adapter/game-adapter.ts`'s kind-tagged `MountedWorld` types can name it
139
+ * `adapter/root-adapter.ts`'s kind-tagged `MountedRoot` types can name it
133
140
  * without an import cycle back to this file. This re-export keeps every
134
141
  * existing `import type { AdapterSurface } from '../runtime/game'` call site
135
- * (`ecs/game-component.ts`, `ecs/component-manager.ts`) compiling unchanged.
142
+ * (`ecs/behavior.ts`, `ecs/component-manager.ts`) compiling unchanged.
136
143
  */
137
144
  export type AdapterSurface = AdapterSurfaceLeaf;
138
145
 
139
146
  /**
140
- * A world's per-phase frame hooks (GAME-ROOT-DESIGN §4, T7.1 slice 2).
141
- * Populated on a `WorldInstance` only for first-party mounts — an opaque/
147
+ * A world's per-phase frame hooks (T7.1 slice 2).
148
+ * Populated on a `RootInstance` only for first-party mounts — an opaque/
142
149
  * foreign mount has no phase-partitioned entry point, so it stays
143
150
  * `undefined` and the Game's frame executor (`GameInternal.runFrame`) falls
144
151
  * back to calling its single `mounted.update(dt)` once per substep instead.
145
152
  */
146
- export interface WorldFrameHooks {
153
+ export interface RootFrameHooks {
147
154
  /** Run this world's engine systems + component ticks + world-bound game
148
155
  * systems for one phase. For a first-party world this delegates to the
149
156
  * SAME `SystemRunner.runPhase` its (legacy) `mounted.update` uses. */
@@ -156,58 +163,55 @@ export interface WorldFrameHooks {
156
163
  }
157
164
 
158
165
  /**
159
- * A single world instance: the unit of adaptation (GAME-ROOT-DESIGN §3).
166
+ * A single world instance: the unit of adaptation.
160
167
  * Slice-1 subset — `gpu` (per-world GPU resource cache, §6.2) is omitted
161
168
  * until the slice that builds it.
162
169
  */
163
- export interface WorldInstance {
170
+ export interface RootInstance {
164
171
  /** Manifest id (T3.1). Slice 1 always registers exactly one: `'main'`. */
165
172
  readonly id: string;
166
173
  readonly kind: AdapterSurface;
167
174
  /** Per-world play/pause semantics (D10, T7.6). Slice 1 always `true`. */
168
175
  readonly pausable: boolean;
169
- /** The GameAdapter that produced `mounted` — first-party or external.
170
- * Deliberately narrower than `GameAdapter<K>` (T7.5): this field is only
171
- * ever read for `.id` (`Game.registerWorld`'s diagnostic message below) —
172
- * never re-invoked — and `GameAdapter<K>`'s `mount` signature legitimately
173
- * differs per kind's host type (a pixijs adapter's `World2DHost`, a react
174
- * adapter's `ReactWorldHost`, vs a threejs adapter's `HostContext`), so
175
- * requiring the FULL interface here would force a cast at every non-
176
- * threejs registration site for no behavioral gain see `AdapterHandle`
177
- * below. */
176
+ /** The RootAdapter that produced `mounted` — first-party or external.
177
+ * Deliberately narrower than `RootAdapter<K>` (T7.5): this field is only
178
+ * ever read for `.id` (`Game.registerRoot`'s diagnostic message below) —
179
+ * never re-invoked — and `RootAdapter<K>`'s `mount` signature legitimately
180
+ * differs per kind's host CONTEXT (`HostContextFor<K>`, P-8: a canvas
181
+ * adapter's `CanvasHostContext`, a react adapter's `DomHostContext`, vs a
182
+ * three adapter's `ThreeHostContext`), so requiring the FULL interface here
183
+ * would force each registration site to name its own `K` for no behavioral
184
+ * gain — see `AdapterHandle` below. */
178
185
  readonly adapter: AdapterHandle;
179
- /** The live mounted surface — one of `MountedWorld`'s kind-tagged shapes
180
- * (T7.5; `MountedThreeWorld` for a threejs world, `MountedPixiWorld` for
181
- * pixijs, `MountedReactWorld` for react). */
182
- readonly mounted: MountedWorld;
186
+ /** The live mounted surface — one of `MountedRoot`'s kind-tagged shapes
187
+ * (T7.5; `MountedThreeRoot` for a three world, `MountedPixiRoot` for
188
+ * canvas, `MountedReactRoot` for react). */
189
+ readonly mounted: MountedRoot;
183
190
  /** Kind-narrowed accessor: throws a descriptive error when this world is
184
- * not a threejs world. */
185
- threeRoot(): THREE.Scene;
186
- /** Kind-narrowed accessor for pixijs roots (T7.3): returns the stage
187
- * passed to `createWorldInstance` for a `'pixijs'`-kind world. Throws
188
- * descriptively for a non-pixijs world, or a pixijs world built without
189
- * a `stage` (see `WorldInstanceInit.stage`). */
190
- pixiRoot(): PIXI.Container;
191
- /** Kind-narrowed accessor for react roots (T6.2 slice 1,
192
- * `docs/REACT-WORLD-DESIGN.md` §1.B): returns the DOM-root layer
193
- * `<div>` the host mounted this world's react tree into (the SAME
194
- * element passed as `container` to `createWorldInstance` — identity
195
- * matters, mirroring `threeRoot()`/`pixiRoot()`'s "same instance the
196
- * adapter mounted" contract). Throws descriptively for a non-react
197
- * world, or a react world built without a `container` (see
198
- * `WorldInstanceInit.container`). */
191
+ * not a three world. */
192
+ threeScene(): THREE.Scene;
193
+ /** Kind-narrowed accessor for canvas roots (T7.3): returns the stage
194
+ * passed to `createRootInstance` for a `'canvas'`-kind world. Throws
195
+ * descriptively for a non-pixijs world, or a canvas world built without
196
+ * a `stage` (see `RootInstanceInit.stage`). */
197
+ pixiStage(): PIXI.Container;
198
+ /** Kind-narrowed accessor for react roots (T6.2 slice 1): returns the
199
+ * DOM-root layer `<div>` the host mounted this world's react tree into
200
+ * (the SAME element passed as `container` to `createRootInstance`
201
+ * identity matters, mirroring `threeScene()`/`pixiStage()`'s "same
202
+ * instance the adapter mounted" contract). Throws descriptively for a
203
+ * non-react world, or a react world built without a `container` (see
204
+ * `RootInstanceInit.container`). */
199
205
  reactRoot(): HTMLElement;
200
206
  /** Present only when the world's mount is first-party (Rapier3D for
201
- * threejs roots). */
207
+ * three roots). */
202
208
  readonly physics?: PhysicsRegistry | undefined;
203
209
  readonly collisions?: CollisionSystem | undefined;
204
- /** Present only when the world's mount is a first-party pixijs world
205
- * (Rapier2D, T7.3) — the 2D analog of `physics`/`collisions` above. Kept
206
- * as separate fields (not folded into `physics`/`collisions` as a union)
207
- * so 3D call sites keep their non-union `PhysicsRegistry`/`CollisionSystem`
208
- * typing unchanged. */
210
+ /** Present only when the world's mount owns a Rapier-2D world — the 2D
211
+ * analog of `physics` above. Kept as a separate field (not folded into
212
+ * `physics` as a union) so 3D call sites keep their non-union
213
+ * `PhysicsRegistry` typing unchanged. */
209
214
  readonly physics2d?: Physics2DRegistry | undefined;
210
- readonly collisions2d?: Collision2DSystem | undefined;
211
215
  /** Kind-typed via `mounted` in T7.5; `unknown` here deliberately. */
212
216
  readonly camera?: unknown;
213
217
  /** Phase-partitioned frame entry point (T7.1 slice 2) — present only for
@@ -215,55 +219,54 @@ export interface WorldInstance {
215
219
  * `GameInternal.runFrame` drives via its single `mounted.update` call
216
220
  * instead (unless it `drivesOwnLoop`, in which case it isn't ticked at
217
221
  * all — see `runFrame`). */
218
- readonly frame?: WorldFrameHooks | undefined;
222
+ readonly frame?: RootFrameHooks | undefined;
219
223
  }
220
224
 
221
225
  /**
222
- * Minimal identity surface `WorldInstance.adapter` needs (T7.5) — see that
223
- * field's doc comment for why it's narrower than `GameAdapter<K>`. Any real
224
- * adapter object (a NAMED type, not a fresh object literal) — `GameAdapter<K>`,
225
- * `Pixi2DGameAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
226
+ * Minimal identity surface `RootInstance.adapter` needs (T7.5) — see that
227
+ * field's doc comment for why it's narrower than `RootAdapter<K>`. Any real
228
+ * adapter object (a NAMED type, not a fresh object literal) — `RootAdapter<K>`,
229
+ * `Pixi2DRootAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
226
230
  * satisfies this trivially (extra members beyond `id` are always fine for a
227
231
  * non-literal source); a bare `{ id, mount }` object literal built INLINE at
228
- * a `createWorldInstance`/`registerXWorld` call site needs an intermediate
232
+ * a `createRootInstance`/`registerXRoot` call site needs an intermediate
229
233
  * `const` (excess-property checking only special-cases fresh literals).
230
234
  */
231
235
  export interface AdapterHandle {
232
236
  readonly id: string;
233
237
  }
234
238
 
235
- /** Inputs to {@link createWorldInstance}. */
236
- export interface WorldInstanceInit {
239
+ /** Inputs to {@link createRootInstance}. */
240
+ export interface RootInstanceInit {
237
241
  readonly id: string;
238
242
  readonly kind: AdapterSurface;
239
243
  /** Defaults to `true` (D10's per-world default). */
240
244
  readonly pausable?: boolean;
241
245
  readonly adapter: AdapterHandle;
242
- readonly mounted: MountedWorld;
243
- /** Required when `kind === 'threejs'` — backs `threeRoot()`. */
246
+ readonly mounted: MountedRoot;
247
+ /** Required when `kind === 'three'` — backs `threeScene()`. */
244
248
  readonly scene?: THREE.Scene | undefined;
245
- /** Required when `kind === 'pixijs'` — backs `pixiRoot()` (T7.3 slice 2,
246
- * symmetric with `scene` for threejs above). */
249
+ /** Required when `kind === 'canvas'` — backs `pixiStage()` (T7.3 slice 2,
250
+ * symmetric with `scene` for three above). */
247
251
  readonly stage?: PIXI.Container | undefined;
248
- /** Required when `kind === 'react'` — backs `reactRoot()` (T6.2 slice 1,
252
+ /** Required when `kind === 'dom'` — backs `reactRoot()` (T6.2 slice 1,
249
253
  * symmetric with `scene`/`stage` above): the DOM-root layer `<div>` this
250
254
  * world's react tree is mounted into. */
251
255
  readonly container?: HTMLElement | undefined;
252
256
  readonly physics?: PhysicsRegistry | undefined;
253
257
  readonly collisions?: CollisionSystem | undefined;
254
258
  readonly physics2d?: Physics2DRegistry | undefined;
255
- readonly collisions2d?: Collision2DSystem | undefined;
256
259
  readonly camera?: unknown;
257
- readonly frame?: WorldFrameHooks | undefined;
260
+ readonly frame?: RootFrameHooks | undefined;
258
261
  }
259
262
 
260
263
  /**
261
- * Build a `WorldInstance` whose kind-narrowed accessors throw descriptively
262
- * on kind mismatch (GAME-ROOT-DESIGN §3). This is the one place that builds
263
- * `threeRoot`/`pixiRoot`, so every world (however it's constructed, in this
264
+ * Build a `RootInstance` whose kind-narrowed accessors throw descriptively
265
+ * on kind mismatch. This is the one place that builds
266
+ * `threeScene`/`pixiStage`, so every world (however it's constructed, in this
264
267
  * slice or later ones) gets identical throw behavior.
265
268
  */
266
- export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
269
+ export function createRootInstance(init: RootInstanceInit): RootInstance {
267
270
  const {
268
271
  id,
269
272
  kind,
@@ -276,50 +279,48 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
276
279
  physics,
277
280
  collisions,
278
281
  physics2d,
279
- collisions2d,
280
282
  camera,
281
283
  frame,
282
284
  } = init;
283
285
 
284
- // A threejs world with no scene has nothing for `threeRoot()` to return —
285
- // fail loudly HERE, at construction, rather than letting `threeRoot()`
286
- // throw its generic "not a threejs world" message later for a world whose
287
- // kind IS threejs (checklist item 5; a misleading error for this case).
288
- if (kind === 'threejs' && !scene) {
286
+ // A three world with no scene has nothing for `threeScene()` to return —
287
+ // fail loudly HERE, at construction, rather than letting `threeScene()`
288
+ // throw its generic "not a three world" message later for a world whose
289
+ // kind IS three (checklist item 5; a misleading error for this case).
290
+ if (kind === 'three' && !scene) {
289
291
  throw new Error(
290
- `WorldInstance "${id}" (kind: threejs): a threejs world requires a scene — ` +
291
- 'pass `scene` in WorldInstanceInit.',
292
+ `RootInstance "${id}" (kind: three): a three world requires a scene — ` +
293
+ 'pass `scene` in RootInstanceInit.',
292
294
  );
293
295
  }
294
296
 
295
- // Symmetric check for pixijs (T7.3 slice 2) — a pixijs world with no stage
296
- // has nothing for `pixiRoot()` to return; fail loudly here, at
297
- // construction, same as the threejs/scene check above.
298
- if (kind === 'pixijs' && !stage) {
297
+ // Symmetric check for canvas (T7.3 slice 2) — a canvas world with no stage
298
+ // has nothing for `pixiStage()` to return; fail loudly here, at
299
+ // construction, same as the three/scene check above.
300
+ if (kind === 'canvas' && !stage) {
299
301
  throw new Error(
300
- `WorldInstance "${id}" (kind: pixijs): a pixijs world requires a stage — ` +
301
- 'pass `stage` in WorldInstanceInit.',
302
+ `RootInstance "${id}" (kind: canvas): a canvas world requires a stage — ` +
303
+ 'pass `stage` in RootInstanceInit.',
302
304
  );
303
305
  }
304
306
 
305
- // Symmetric check for react (T6.2 slice 1, docs/REACT-WORLD-DESIGN.md
306
- // §1.B) — a react world with no container has nothing for `reactRoot()`
307
- // to return; fail loudly here, at construction, same as the two checks
308
- // above.
309
- if (kind === 'react' && !container) {
307
+ // Symmetric check for react (T6.2 slice 1) — a react world with no
308
+ // container has nothing for `reactRoot()` to return; fail loudly here,
309
+ // at construction, same as the two checks above.
310
+ if (kind === 'dom' && !container) {
310
311
  throw new Error(
311
- `WorldInstance "${id}" (kind: react): a react world requires a container — ` +
312
- 'pass `container` in WorldInstanceInit.',
312
+ `RootInstance "${id}" (kind: react): a react world requires a container — ` +
313
+ 'pass `container` in RootInstanceInit.',
313
314
  );
314
315
  }
315
316
 
316
- // Disposed-world guard (GAME-ROOT-DESIGN §8 stage 3 / T7.1 slice 3). There
317
- // is no `unregisterWorld` (decision 4 — roots are manifest-declared; a
317
+ // Disposed-world guard (T7.1 slice 3). There
318
+ // is no `unregisterRoot` (decision 4 — roots are manifest-declared; a
318
319
  // whole Game is disposed, not one world out of its registry), so a caller
319
320
  // that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
320
- // leaves that `WorldInstance` sitting in `Game.roots` — and `runFrame`
321
+ // leaves that `RootInstance` sitting in `Game.roots` — and `runFrame`
321
322
  // would otherwise keep invoking its (now-torn-down) frame hooks every
322
- // subsequent frame. `MountedGame` has no public "am I disposed" flag to
323
+ // subsequent frame. `MountedThreeRoot` has no public "am I disposed" flag to
323
324
  // read, so this wraps `mounted.dispose` in place (mutating the SAME mount
324
325
  // object every holder of `mounted` shares — calling `mounted.dispose()`
325
326
  // directly, exactly like calling `world.mounted.dispose()`, trips this)
@@ -334,7 +335,7 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
334
335
  disposed = true;
335
336
  originalDispose();
336
337
  };
337
- const guardedFrame: WorldFrameHooks | undefined = frame
338
+ const guardedFrame: RootFrameHooks | undefined = frame
338
339
  ? {
339
340
  runPhase(phase, dt) {
340
341
  if (disposed) return;
@@ -356,26 +357,25 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
356
357
  physics,
357
358
  collisions,
358
359
  physics2d,
359
- collisions2d,
360
360
  camera,
361
361
  frame: guardedFrame,
362
- threeRoot(): THREE.Scene {
363
- if (kind !== 'threejs' || !scene) {
362
+ threeScene(): THREE.Scene {
363
+ if (kind !== 'three' || !scene) {
364
364
  throw new Error(
365
- `WorldInstance "${id}" (kind: ${kind}): threeRoot() requested but this world is not ` +
366
- 'a threejs world',
365
+ `RootInstance "${id}" (kind: ${kind}): threeScene() requested but this world is not ` +
366
+ 'a three world',
367
367
  );
368
368
  }
369
369
  return scene;
370
370
  },
371
- pixiRoot(): PIXI.Container {
372
- // The `!stage` branch a pixijs world could hit here is now unreachable
373
- // (T7.3 slice 2): construction above throws for `kind === 'pixijs'`
374
- // with no `stage`, symmetric with `threeRoot()`'s `scene` guard.
375
- if (kind !== 'pixijs' || !stage) {
371
+ pixiStage(): PIXI.Container {
372
+ // The `!stage` branch a canvas world could hit here is now unreachable
373
+ // (T7.3 slice 2): construction above throws for `kind === 'canvas'`
374
+ // with no `stage`, symmetric with `threeScene()`'s `scene` guard.
375
+ if (kind !== 'canvas' || !stage) {
376
376
  throw new Error(
377
- `WorldInstance "${id}" (kind: ${kind}): pixiRoot() requested but this world is not ` +
378
- 'a pixijs world',
377
+ `RootInstance "${id}" (kind: ${kind}): pixiStage() requested but this world is not ` +
378
+ 'a canvas world',
379
379
  );
380
380
  }
381
381
  return stage;
@@ -383,11 +383,11 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
383
383
  reactRoot(): HTMLElement {
384
384
  // The `!container` branch a react world could hit here is now
385
385
  // unreachable (T6.2 slice 1): construction above throws for
386
- // `kind === 'react'` with no `container`, symmetric with
387
- // `threeRoot()`/`pixiRoot()`'s guards.
388
- if (kind !== 'react' || !container) {
386
+ // `kind === 'dom'` with no `container`, symmetric with
387
+ // `threeScene()`/`pixiStage()`'s guards.
388
+ if (kind !== 'dom' || !container) {
389
389
  throw new Error(
390
- `WorldInstance "${id}" (kind: ${kind}): reactRoot() requested but this world is not ` +
390
+ `RootInstance "${id}" (kind: ${kind}): reactRoot() requested but this world is not ` +
391
391
  'a react world',
392
392
  );
393
393
  }
@@ -397,40 +397,131 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
397
397
  }
398
398
 
399
399
  /**
400
- * Type guard for whether a `MountedGame` is a first-party
401
- * `VgaiSceneGameAdapter` mount (has a live `GameContext` at `.ctx`). Used to
400
+ * Type guard for whether a `MountedThreeRoot` is a first-party
401
+ * `SetupThreeRootAdapter` mount (has a live `GameContext` at `.ctx`). Used to
402
402
  * decide whether a world's `physics`/`collisions`/`camera` (and, on `Game`,
403
- * `components`/`input`/`audio`) can be populated from it — an external
403
+ * `input`/`audio`) can be populated from it — an external
404
404
  * adapter's mount has none of these first-party handles.
405
405
  *
406
406
  * Checks the `firstParty: true` brand (checklist item 1), NOT `'ctx' in
407
- * mounted` — world2d's `MountedGame2D` (`world2d/pixi-game-adapter.ts`) also
408
- * has a `ctx` key (a `World2DContext`, unrelated to `GameContext`), so a
409
- * structural `'ctx' in mounted` check would misfire once T7.3 registers 2D
410
- * roots onto the same `Game`. The probe is a plain property read, not an
411
- * `instanceof`/value import of `vgai-scene-game-adapter.ts` — the
412
- * `VgaiMountedGame` import above stays type-only.
407
+ * mounted` — a foreign mount may carry an unrelated `ctx` key, so a
408
+ * structural `'ctx' in mounted` check would misfire. The probe is a plain property read, not an
409
+ * `instanceof`/value import of `setup-three-root-adapter.ts` the
410
+ * `MountedSetupThreeRoot` import above stays type-only.
413
411
  */
414
- export function isFirstPartyMounted(mounted: MountedWorld): mounted is VgaiMountedGame {
412
+ export function isFirstPartyMounted(mounted: MountedRoot): mounted is MountedSetupThreeRoot {
415
413
  return (mounted as { firstParty?: unknown }).firstParty === true;
416
414
  }
417
415
 
418
416
  /**
419
- * The Game root (GAME-ROOT-DESIGN §3). Owns the one loop, the raw-asset
417
+ * One world's half of a debris disposal, as {@link disposeDebrisSubtree} needs
418
+ * it. A first-party `GameContext` satisfies this structurally.
419
+ *
420
+ * `physics`/`rapierWorld` are OPTIONAL: an owner that builds no first-party
421
+ * Rapier runtime (an R3F world) carries neither, and {@link
422
+ * disposeDebrisSubtree} reads them behind `readRapierHandles` rather than
423
+ * unconditionally.
424
+ */
425
+ export interface DebrisOwner {
426
+ readonly physics?: GameContext['physics'];
427
+ readonly rapierWorld?: GameContext['rapierWorld'];
428
+ }
429
+
430
+ /**
431
+ * Read an owner's Rapier handles, treating an absent pair as "this owner holds
432
+ * no rigid bodies" — the honest reading for a world (R3F, or a bare
433
+ * `ComponentManager`-only owner) that builds no first-party physics runtime.
434
+ */
435
+ function readRapierHandles(owner: DebrisOwner): {
436
+ physics: NonNullable<DebrisOwner['physics']>;
437
+ rapierWorld: GameContext['rapierWorld'];
438
+ } | null {
439
+ const { physics, rapierWorld } = owner;
440
+ if (!physics || !rapierWorld) return null;
441
+ return { physics, rapierWorld };
442
+ }
443
+
444
+ /**
445
+ * P3 — what `SimClock.disposeAfter` actually does. `core/sim-clock.ts` knows
446
+ * only *when*; this is the *what*, and it lives here because it is the runtime
447
+ * that knows about Rapier and shared geometry.
448
+ *
449
+ * The order mirrors `adapter/setup-three-root-adapter.ts`'s world teardown for
450
+ * ONE subtree. Within the physics step,
451
+ * `rapierWorld.removeRigidBody(body)` comes BEFORE `physics.remove(node)`: the
452
+ * registry's `remove()` only drops index entries, so reversing the two leaks
453
+ * the Rapier body.
454
+ *
455
+ * `owners` is every world that could own part of the subtree — `createGame`
456
+ * passes all of its first-party three roots as their `GameContext`, a bare
457
+ * adapter mount passes its own ctx. `physics.get` is a no-op for a node the
458
+ * owner does not own, so offering the subtree to each is safe.
459
+ *
460
+ * Safe on an object already removed or already disposed: `removeFromParent` on
461
+ * a parentless `Object3D` is a no-op and three's `dispose()` calls are
462
+ * idempotent.
463
+ */
464
+ export function disposeDebrisSubtree(
465
+ obj: THREE.Object3D,
466
+ owners: ReadonlyArray<DebrisOwner>,
467
+ ): void {
468
+ const nodes: THREE.Object3D[] = [];
469
+ obj.traverse((node) => nodes.push(node));
470
+ for (const owner of owners) {
471
+ const rapier = readRapierHandles(owner);
472
+ if (!rapier) continue;
473
+ for (const node of nodes) {
474
+ const refs = rapier.physics.get(node);
475
+ if (!refs) continue;
476
+ rapier.rapierWorld.removeRigidBody(refs.body);
477
+ rapier.physics.remove(node);
478
+ }
479
+ }
480
+ obj.removeFromParent();
481
+ for (const node of nodes) {
482
+ // Duck-typed rather than `instanceof THREE.Mesh` so this module keeps its
483
+ // TYPE-ONLY three import (it is surface-neutral — it also hosts pixi
484
+ // roots). Covers Points/Line/Sprite debris too, which a Mesh check would
485
+ // silently leak.
486
+ const drawable = node as Partial<THREE.Mesh>;
487
+ if (!hasUserData(node, '__sharedGeometry')) drawable.geometry?.dispose();
488
+ const material = drawable.material;
489
+ if (Array.isArray(material)) {
490
+ for (const m of material) m.dispose();
491
+ } else {
492
+ material?.dispose();
493
+ }
494
+ }
495
+ }
496
+
497
+ /**
498
+ * The {@link DebrisOwner}s one registered world contributes: its `GameContext`
499
+ * when the mount is first-party, else nothing. Written as a standalone function
500
+ * so `createGame`'s clock disposer is one line and the "which mounts count"
501
+ * rule has exactly one home.
502
+ */
503
+ function debrisOwnersOf(world: RootInstance): DebrisOwner[] {
504
+ return isFirstPartyMounted(world.mounted) ? [world.mounted.ctx] : [];
505
+ }
506
+
507
+ /**
508
+ * The Game root. Owns the one loop, the raw-asset
420
509
  * cache, the world registry, and (T7.1 slice 2) the game-scoped
421
- * `SystemRunner`. `components`/`input`/`audio` remain slice-1 late
510
+ * `SystemRunner`. `input`/`audio` remain slice-1 late
422
511
  * additions: they delegate to the default world's first-party mount so
423
512
  * existing single-world call sites keep working; hoisting them to true
424
- * Game ownership (GAME-ROOT-DESIGN §3's target shape) is a later slice's
513
+ * Game ownership (the target shape) is a later slice's
425
514
  * work, not this one's.
426
515
  */
427
516
  export interface Game {
428
517
  readonly loop: GameLoop;
429
518
  readonly assets: AssetCache;
519
+ /** Host identity for this private play run or coordinated Team Test. */
520
+ readonly playtest?: PlaytestContext | null;
430
521
  /** Per-game diagnostic store. Disabled by default; the editor enables it on demand. */
431
522
  readonly profiler: PerformanceProfiler;
432
523
  /**
433
- * The game-scoped `SystemRunner` (GAME-ROOT-DESIGN §4, T7.1 slice 2) — a
524
+ * The game-scoped `SystemRunner` (T7.1 slice 2) — a
434
525
  * NEW bucket, separate from any world's own runner. Within each phase,
435
526
  * `GameInternal.runFrame` runs THIS runner's `runPhase` first, before any
436
527
  * world's engine systems/component ticks/world-bound game systems (e.g.
@@ -441,111 +532,114 @@ export interface Game {
441
532
  */
442
533
  readonly systems: SystemRunner;
443
534
  /** Declaration-ordered. Slice 1 registers exactly one (the default
444
- * threejs world) — this is the SAME array reference `registerWorld`
535
+ * three world) — this is the SAME array reference `registerRoot`
445
536
  * mutates, not a snapshot, so holders (e.g. `GameContext.roots`) observe
446
537
  * later registrations. */
447
- readonly roots: ReadonlyArray<WorldInstance>;
448
- world(id: string): WorldInstance | null;
449
- /** First threejs world, else first world. Throws descriptively when no
538
+ readonly roots: ReadonlyArray<RootInstance>;
539
+ world(id: string): RootInstance | null;
540
+ /** First three world, else first world. Throws descriptively when no
450
541
  * world has been registered yet. */
451
- readonly defaultWorld: WorldInstance;
542
+ readonly defaultRoot: RootInstance;
452
543
  /**
453
- * Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3,
454
- * `docs/GAME-ROOT-DESIGN.md` §3: "`registerSystemAdapter` is game-scoped" —
455
- * the recorded decision this getter finally implements; probe1). Each
456
- * mounted world builds its OWN `mounted.systems` object (a game registers
457
- * capabilities like `networking` from ITS OWN `setup()`, via
458
- * `ctx.registerSystemAdapter`, per-world) — this merges every world's
459
- * `mounted.systems` into ONE `SystemAdapters`, in `roots` REGISTRATION
460
- * order, first registration wins per key. A later world registering the
461
- * SAME kind (e.g. two roots both exposing `networking`) does not
462
- * override the first instead this warns ONCE per (game instance, key)
463
- * naming both world ids, matching this file's `[game] world "<id>" …`
464
- * console idiom (see `registerWorld` below). NOT to be confused with
465
- * `Game.systems` (the game-scoped `SystemRunner` bucket, an entirely
466
- * different concept see that field's doc comment) — this name was
467
- * deliberately chosen not to collide with it.
544
+ * Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3:
545
+ * "`registerSystemAdapter` is game-scoped" — the recorded decision this
546
+ * getter finally implements; probe1). Each mounted world builds its OWN
547
+ * `mounted.systems` object (a game registers capabilities like `networking`
548
+ * from ITS OWN `setup()`, via `ctx.registerSystemAdapter`, per-world) —
549
+ * this merges every world's `mounted.systems` into ONE `SystemAdapters`, in
550
+ * `roots` REGISTRATION order, first registration wins per key. A later
551
+ * world registering the SAME kind (e.g. two roots both exposing
552
+ * `networking`) does not override the first instead this warns ONCE per
553
+ * (game instance, key) naming both world ids, matching this file's `[game]
554
+ * world "<id>" …` console idiom (see `registerRoot` below). NOT to be
555
+ * confused with `Game.systems` (the game-scoped `SystemRunner` bucket, an
556
+ * entirely different concept see that field's doc comment) — this name
557
+ * was deliberately chosen not to collide with it.
468
558
  *
469
559
  * A plain getter (recomputed on every read, not cached) so a LATER
470
- * `registerWorld` call (or a world's setup registering a NEW adapter kind
560
+ * `registerRoot` call (or a world's setup registering a NEW adapter kind
471
561
  * after this was first read) is always reflected — only the COLLISION
472
562
  * warning is deduped (once per key, for the lifetime of this `Game`).
473
563
  * Includes every world regardless of first-party-ness — `mounted.systems`
474
564
  * is a capability any adapter (first-party or foreign) may expose.
475
565
  */
476
566
  readonly systemAdapters: SystemAdapters;
477
- /** Delegates to the default world's first-party `ComponentManager`.
478
- * Throws when the default world is not a first-party mount. */
479
- readonly components: ComponentManager;
567
+ /** Subscribe when a mounted root registers or replaces a system adapter. */
568
+ subscribeSystemAdapters?(listener: () => void): () => void;
480
569
  /** The one game-owned `InputManager`, shared by every first-party root. */
481
570
  readonly input: InputManager;
482
571
  /** Delegates to the default world's first-party audio context. Throws
483
572
  * when the default world is not a first-party mount. */
484
573
  readonly audio: GameAudio;
485
574
  /**
486
- * Frame-versioned state bridge (T7.4 slice 1 `docs/REACT-STATE-BRIDGE.md`
487
- * §2). Bumped once per completed `runFrame`, after all phases of all
488
- * roots and all `endFrame` hooks (see `runFrame`'s tail below). This is
489
- * the ONE subscription surface `useGameState`
490
- * (`@vgai/engine/react/game-state`) or any
491
- * other frame-versioned consumer subscribes to; `state-bridge.ts` itself
492
- * has no react import, matching the rest of `runtime/`.
575
+ * Frame-versioned state bridge (T7.4 slice 1). Bumped once per completed
576
+ * `runFrame`, after all phases of all roots and all `endFrame` hooks (see
577
+ * `runFrame`'s tail below). This is the ONE subscription surface
578
+ * `useWorldState` (`@vgai/engine/react/world-state`) or any other
579
+ * frame-versioned consumer — subscribes to; `state-bridge.ts` itself has no
580
+ * react import, matching the rest of `runtime/`.
493
581
  */
494
582
  readonly state: GameStateBridge;
495
583
  /** Game-level play-state control surface (D10, T7.6) — see {@link PlayState}. */
496
584
  readonly play: PlayState;
497
585
  /**
498
- * Cross-world component query (GAME-ROOT-DESIGN §5, T7.1 slice 3) the
499
- * game-level half; `ComponentManager.queryByComponent` (`ecs/
500
- * component-manager.ts`) is the per-world half this aggregates.
586
+ * WO-P13 the interpolation alpha the most recent display frame presented
587
+ * at: `accumulator / fixedDt`, in `[0, 1]`. `0` means "exactly on the last
588
+ * completed fixed state", `0.5` means "halfway to the next one".
501
589
  *
502
- * Iterates `roots` in declaration order, querying each world's
503
- * `ComponentManager` the first-party mount's `ctx.components`, or the
504
- * optional `MountedWorldBase.components` capability for a non-first-party
505
- * adapter that runs a real manager (e.g. `@engine/world3d-react`'s R3F
506
- * mount — its `<Behavior>` components are aggregated here exactly like
507
- * scene-authored ones). A mount with neither is skipped (an opaque/foreign
508
- * world hosts no `GameComponent`s). `opts.worldId`/`opts.kind` apply as
509
- * world-level filters; the per-world results are concatenated. No filter (`opts` omitted) spans **every** world — that is the
510
- * whole point of this API existing (D7's "cross-world flow is ordinary
511
- * component access", e.g. a pixi minimap component querying the 3D
512
- * world's tank components).
590
+ * Read it to interpolate your own transforms between the last two fixed
591
+ * states; it is the same number `onRenderStep` hands its callbacks, exposed
592
+ * here for code that renders from somewhere other than a callback. Stays
593
+ * `0` for a Game whose host never wired a display-rate render pass (see
594
+ * {@link onRenderStep}).
595
+ */
596
+ readonly renderAlpha: number;
597
+ /**
598
+ * WO-P13 the `RenderStepped`-shaped host: register a callback that runs
599
+ * ONCE PER DISPLAY FRAME, before that frame's `preRender`/`render` phases,
600
+ * with `(alpha, displayDt)`.
601
+ *
602
+ * This is the seam for presentation-only work whose natural rate is the
603
+ * monitor's, not the simulation's — camera polish/smoothing, procedural
604
+ * sway, a cosmetic bob. It is NOT a gameplay hook: it can fire twice
605
+ * between two fixed substeps (120 Hz display, 60 Hz sim) and zero times
606
+ * across a `runTicks` fast-forward, so anything that must be deterministic
607
+ * belongs in a fixed phase (`ctx.systems.add`, a `useFrame` hook, a
608
+ * `useFrame`) instead.
513
609
  *
514
- * Returns component **instances**, not nodes `inst.world`/typed node
515
- * accessors (`inst.object3D`, the generic `inst.node`) are T7.2's; this
516
- * API's contract freezes HERE. The physical ONE-`ComponentManager`
517
- * unification (partitioning one manager by (phase, world) instead of one
518
- * manager per world) lands with T7.2/T7.3 this per-world-aggregation
519
- * implementation is the compatible interim: callers written against this
520
- * signature keep working unchanged once the manager is unified
521
- * underneath.
610
+ * Reached from game code as `ctx.game?.onRenderStep(...)`. Returns an
611
+ * unsubscribe function; pass `opts.signal` to unsubscribe with the same
612
+ * `AbortSignal` a `setup()` already uses for its listeners and sim timers
613
+ * (`core/sim-clock.ts`'s ownership section cancelling what you registered
614
+ * is the game's job, and a stale render-step closure is the same hazard
615
+ * class as a stale event listener).
522
616
  *
523
- * `T extends GameComponent<AdapterSurface>` (not the bare default-`'threejs'`
524
- * `GameComponent`, T7.3 slice 2) the whole point of a CROSS-WORLD query
525
- * is spanning every kind in one call (the T7.3 AC,
526
- * `test/game-three-plus-pixi.test.ts`), so a `GameComponent<'pixijs'>`
527
- * subclass must type-check here exactly like a default-kind one does.
617
+ * Silent-no-op honesty: a callback registered on a Game whose host wired no
618
+ * display-rate render pass (a bare test harness, a `drivesOwnLoop`-only
619
+ * mount, an `externalDrive` capture page) never fires, because nothing
620
+ * drives it. That is the same shape as `ctx.clock` on a Game-less mount.
528
621
  */
529
- queryByComponent<T extends GameComponent<AdapterSurface>>(
530
- cls: new () => T,
531
- opts?: { worldId?: string; kind?: AdapterSurface },
532
- ): T[];
622
+ onRenderStep(
623
+ fn: (alpha: number, displayDt: number) => void,
624
+ opts?: { signal?: AbortSignal | undefined },
625
+ ): () => void;
533
626
  }
534
627
 
535
628
  /**
536
- * Host-internal extension of {@link Game}: adds `registerWorld` (the host's
629
+ * Host-internal extension of {@link Game}: adds `registerRoot` (the host's
537
630
  * wiring surface for populating the world registry) and `runFrame` (the
538
631
  * frame executor). NEITHER is part of the game-facing `Game` surface —
539
- * games/components never call either directly; the host loop
632
+ * games never call either directly; the host loop
540
633
  * (`createGameRuntime`) and `GameSession.step()` are the only callers of
541
634
  * `runFrame`.
542
635
  */
543
636
  export interface GameInternal extends Game {
637
+ /** Host-side signal used by root contexts after `registerSystemAdapter`. */
638
+ notifySystemAdaptersChanged(): void;
544
639
  /** Append a world in declaration order. Throws on a duplicate id. */
545
- registerWorld(world: WorldInstance): void;
640
+ registerRoot(world: RootInstance): void;
546
641
  /**
547
- * Run ONE fixed substep across every phase and every world
548
- * (GAME-ROOT-DESIGN §4):
642
+ * Run ONE fixed substep across every phase and every world:
549
643
  *
550
644
  * ```
551
645
  * for phase in PHASE_ORDER:
@@ -585,19 +679,55 @@ export interface GameInternal extends Game {
585
679
  * `endFrame` with the real `dt` (not the render-phase's forced `0`), and
586
680
  * touches no other world at all — a natural no-op while not paused, since
587
681
  * the frozen set is then empty.
682
+ *
683
+ * WO-P13 addendum: `opts.skipRenderPhases` omits `preRender`+`render` from
684
+ * the pass — every other phase, and `endFrame`, run exactly as always. A
685
+ * host that drives presentation at DISPLAY rate sets it on every substep
686
+ * and calls {@link runRenderFrame} once per real frame instead
687
+ * (`create-runtime.ts`). A host that does
688
+ * not set it keeps the pre-WO-P13 shape verbatim — which is why
689
+ * `externalDrive` capture (`render-control.ts`'s `simulateSubsteps`, which
690
+ * calls `runFrame(fixedDt)` with no opts) is frame-exact and unchanged.
691
+ * `runTicks` sets it per tick for its `render: 'none' | 'last'` modes.
692
+ */
693
+ runFrame(dt: number, opts?: { ignorePause?: boolean; skipRenderPhases?: boolean }): void;
694
+ /**
695
+ * WO-P13 — run ONE display frame's presentation pass: the registered
696
+ * `onRenderStep` callbacks, then the `preRender` and `render` phases across
697
+ * game-scoped systems and every host-driven world, in the same
698
+ * game-systems-then-worlds order {@link runFrame} uses.
699
+ *
700
+ * ```
701
+ * for cb in onRenderStep callbacks: cb(alpha, displayDt)
702
+ * for phase in [preRender, render]:
703
+ * game.systems.runPhase(phase, displayDt)
704
+ * for world in roots (declaration order):
705
+ * if world.mounted.drivesOwnLoop: continue
706
+ * world.frame?.runPhase(phase, frozen ? 0 : displayDt)
707
+ * ```
708
+ *
709
+ * Advances NOTHING: no `tick`, no `simT`, no sim-clock flush, no state-bridge
710
+ * bump, no `endFrame`. It is presentation only, which is what makes calling
711
+ * it at a rate the simulation does not share safe in the first place.
712
+ *
713
+ * D10's pause rule carries over unchanged in substance: a frozen world
714
+ * (paused + `pausable`) still RENDERS — that is what keeps a paused editor
715
+ * viewport painted — but with `dt` forced to `0`, so no time-based render
716
+ * effect animates a frozen scene. What changes is only the cadence: once per
717
+ * display frame instead of once per fixed substep.
588
718
  */
589
- runFrame(dt: number, opts?: { ignorePause?: boolean }): void;
719
+ runRenderFrame(alpha: number, displayDt: number): void;
590
720
  /**
591
- * D15/T-D15.3-.4 (`docs/D15-DETERMINISM-DESIGN.md` §2.b) — a deterministic
592
- * fast-forward primitive: synchronously call the SAME per-tick pipeline
593
- * `runFrame` uses, `n` times in a tight loop, with `dt` fixed to the host
594
- * loop's own fixed timestep (`this.loop.fixedDt` — `core/game-loop.ts`).
595
- * This generalizes `render-control.ts`'s proven `VgaiRenderHarness.
596
- * simulateSubsteps` from the capture-only door (`?vgai-render=1`) to a
597
- * Game-level primitive every door can reach (the bridge's
598
- * `window.__vgai.runTicks`, the editor relay's `run-ticks` case
599
- * `play.runTicks`) `simulateSubsteps` itself is UNTOUCHED by this
600
- * addition (it may later delegate to this method; not this unit's job).
721
+ * D15/T-D15.3-.4 — a deterministic fast-forward primitive: synchronously
722
+ * call the SAME per-tick pipeline `runFrame` uses, `n` times in a tight
723
+ * loop, with `dt` fixed to the host loop's own fixed timestep
724
+ * (`this.loop.fixedDt` — `core/game-loop.ts`). This generalizes
725
+ * `render-control.ts`'s proven `VgaiRenderHarness. simulateSubsteps` from
726
+ * the capture-only door (`?vgai-render=1`) to a Game-level primitive every
727
+ * door can reach (the bridge's `window.__vgai.runTicks`, the editor relay's
728
+ * `run-ticks` case → `play.runTicks`) `simulateSubsteps` itself is
729
+ * UNTOUCHED by this addition (it may later delegate to this method; not
730
+ * this unit's job).
601
731
  *
602
732
  * Semantics:
603
733
  * - **Decoupled from wall clock and the accumulator.** `runTicks` drives
@@ -620,7 +750,7 @@ export interface GameInternal extends Game {
620
750
  * never renders, not even the last tick. `tick`/`simT`/
621
751
  * `stateBridge.bump()`/the debug event ring advance identically on
622
752
  * EVERY tick regardless of `render` — only the paint-affecting phases
623
- * are skipped, so state watchers (`useGameState`, debug state providers)
753
+ * are skipped, so state watchers (`useWorldState`, debug state providers)
624
754
  * stay correct even when fast-forwarding with no visible output.
625
755
  * - **Refuses while paused.** Throws a structured `DebugError`
626
756
  * (`code: 'RUN_TICKS_PAUSED'`) if `Game.play.paused` is true —
@@ -637,8 +767,8 @@ export interface GameInternal extends Game {
637
767
  runTicks(n: number, opts?: RunTicksOptions): void;
638
768
  /**
639
769
  * ADAPTER-MOUNT surface, not game-facing: an adapter's `mount()` calls
640
- * this (via `host.game` — the classic `VgaiSceneGameAdapter` and the R3F
641
- * `createR3FWorldContext` both do) to load the game-owned input map ONCE,
770
+ * this (via `host.game` — the classic `SetupThreeRootAdapter` and the R3F
771
+ * `createR3FRootContext` both do) to load the game-owned input map ONCE,
642
772
  * even when several roots ask for the same path; competing paths throw
643
773
  * (input is game-owned, so roots cannot load competing maps). GAME code
644
774
  * never calls this — a project's map loads automatically at mount from
@@ -651,7 +781,7 @@ export interface GameInternal extends Game {
651
781
  dispose(): void;
652
782
  }
653
783
 
654
- function describeMismatch(handle: 'components' | 'input' | 'audio', world: WorldInstance): string {
784
+ function describeMismatch(handle: 'input' | 'audio', world: RootInstance): string {
655
785
  return (
656
786
  `Game.${handle}: default world "${world.id}" (kind: ${world.kind}) is not a first-party ` +
657
787
  "mount — hoisting these to true Game ownership is a later slice's work; available only " +
@@ -662,31 +792,32 @@ function describeMismatch(handle: 'components' | 'input' | 'audio', world: World
662
792
  /**
663
793
  * Construct the (host-internal) Game shell. Callers: `createGameRuntime`
664
794
  * builds this BEFORE mounting its one adapter, then registers the default
665
- * threejs world once mount resolves (see `registerThreeWorld` in
795
+ * three world once mount resolves (see `registerThreeRoot` in
666
796
  * `create-runtime.ts`).
667
797
  */
668
798
  export function createGame(opts: {
669
799
  loop: GameLoop;
670
800
  assets: AssetCache;
801
+ playtest?: PlaytestContext | null | undefined;
671
802
  /** D15 (T-D15.1) — the root seed `ctx.random` boots from, on every world
672
803
  * mounted onto this Game. Defaults to `DEFAULT_SEEDED_RANDOM_SEED` (a
673
804
  * fixed, non-wall-clock constant — `ctx.random` is always reproducible
674
805
  * on its own terms, whether or not the project's manifest DECLARES that
675
806
  * reproducibility as a contract). The manifest-aware boot path
676
- * (`mount-manifest.ts`'s `mountManifestWorlds`) is what actually resolves
807
+ * (`mount-manifest.ts`'s `mountManifestRoots`) is what actually resolves
677
808
  * `manifest.determinism.defaultSeed`/`?vgai-seed=`/explicit config and
678
809
  * passes the result here, BEFORE any world's `mount()`/`setup()` runs —
679
810
  * `createGameRuntime` always constructs the Game (this call) first (see
680
811
  * this function's own doc comment below). */
681
812
  seed?: number | undefined;
682
813
  }): GameInternal {
683
- const roots: WorldInstance[] = [];
814
+ const roots: RootInstance[] = [];
684
815
  const profiler = createPerformanceProfiler();
685
816
  const systems = createSystemRunner(profiler.systemObserver, 'game');
686
817
  const input = new InputManager();
687
818
  const stateBridge = createStateBridge();
688
819
  // D15 (T-D15.1) — the game-scoped seeded-random surface every world's
689
- // `ctx.random` aliases (see `vgai-scene-game-adapter.ts`'s `ctx.random =
820
+ // `ctx.random` aliases (see `setup-three-root-adapter.ts`'s `ctx.random =
690
821
  // ...`, wired the same way `ctx.debug` is just below). Constructed
691
822
  // unconditionally (cheap — a handful of closures) regardless of whether
692
823
  // this project ever declares `determinism.seededRandom`; only the BOOT
@@ -711,14 +842,39 @@ export function createGame(opts: {
711
842
  return rngTrapEnabled;
712
843
  },
713
844
  };
714
- // T1.2 (docs/SYNTHETIC-PLAYER-SPEC.md §3.1/§3.3): `tick` counts completed
715
- // fixed substeps, `simT` accumulates their `dt` both game-scoped, bumped
716
- // ONLY where `stateBridge.bump()` is (guarded by `advanced`, `runFrame`'s
717
- // tail below), so a paused/frozen frame never advances either. The debug
718
- // registry reads them via suppliers (not by capturing the numbers now)
719
- // so its built-in `time` provider always sees the CURRENT values.
845
+ // `tick` counts completed fixed substeps, `simT` accumulates their `dt` —
846
+ // both game-scoped, bumped ONLY where `stateBridge.bump()` is (guarded by
847
+ // `advanced`, `runFrame`'s tail below), so a paused/frozen frame never
848
+ // advances either. The debug registry reads them via suppliers (not by
849
+ // capturing the numbers now) so its built-in `time` provider always sees
850
+ // the CURRENT values.
720
851
  let tick = 0;
721
852
  let simT = 0;
853
+ // WO-P13 — the display-rate half. `renderAlpha` is the last alpha
854
+ // `runRenderFrameImpl` presented at (0 until a host drives one); the set is
855
+ // the `RenderStepped`-shaped registry `Game.onRenderStep` feeds. Deliberately
856
+ // NOT beside `tick`/`simT` in meaning: neither of these ever advances sim
857
+ // state, which is exactly what makes running them at the monitor's rate safe.
858
+ let renderAlpha = 0;
859
+ const renderStepCallbacks = new Set<(alpha: number, displayDt: number) => void>();
860
+ // P3 — the ONE sim clock this Game owns, declared beside the accumulator it
861
+ // is bound to (`flush(simT)` runs at the tail of `runFrameImpl`, inside the
862
+ // same `advanced` guard as the two bumps above, so a paused/frozen frame
863
+ // fires no timers). Every world's `ctx.clock` is THIS instance, reached the
864
+ // same way `ctx.random`/`ctx.debug` reach their game-scoped singletons —
865
+ // `getSimClock(host.game)` over the WeakMap filed below.
866
+ //
867
+ // The disposer is supplied HERE rather than inside the clock because
868
+ // `core/sim-clock.ts` deliberately knows nothing about Rapier or shared
869
+ // geometry. It mirrors `setup-three-root-adapter.ts`'s world teardown
870
+ // ordering for ONE subtree.
871
+ const simClock: SimClockInternal = createSimClock({
872
+ // Every mount that could own part of the subtree is offered it:
873
+ // `physics.get` is a no-op for a node the mount does not own, so this is
874
+ // correct with several three roots on one Game and needs no ownership
875
+ // bookkeeping.
876
+ dispose: (obj) => disposeDebrisSubtree(obj, roots.flatMap(debrisOwnersOf)),
877
+ });
722
878
  const debugRegistry = createDebugRegistry({
723
879
  getTick: () => tick,
724
880
  getSimT: () => simT,
@@ -726,11 +882,11 @@ export function createGame(opts: {
726
882
  // registry's world-addressed input-target compatibility surface. Every
727
883
  // first-party root now registers the SAME game-owned InputManager, but the
728
884
  // stable default id still keeps explicit/implicit debug routing coherent.
729
- // Use the SAME "first threejs world, else first world" rule
730
- // `requireDefaultWorld` (declared just below — safe: this closure is
885
+ // Use the SAME "first three world, else first world" rule
886
+ // `requireDefaultRoot` (declared just below — safe: this closure is
731
887
  // only ever CALLED later, once at least one world has mounted) already
732
- // defines for `Game.defaultWorld`.
733
- getDefaultWorldId: () => (roots.length > 0 ? requireDefaultWorld().id : null),
888
+ // defines for `Game.defaultRoot`.
889
+ getDefaultRootId: () => (roots.length > 0 ? requireDefaultRoot().id : null),
734
890
  // Issue #175 — the built-in `time` provider's `loopLiveness` field reads
735
891
  // the REAL loop, not any UI-level play-state store: `opts.loop` is the
736
892
  // SAME `GameLoop` this `Game`'s own `.loop` field exposes, so this
@@ -738,7 +894,6 @@ export function createGame(opts: {
738
894
  // the loop is actually ticking.
739
895
  getLoopLiveness: () => opts.loop.liveness,
740
896
  });
741
- const inputAdapter = createInputManagerAdapter(input);
742
897
  let inputFrameActive = false;
743
898
  systems.add(
744
899
  'input',
@@ -747,19 +902,19 @@ export function createGame(opts: {
747
902
  },
748
903
  { name: 'input.poll' },
749
904
  );
750
- input.setDebugEmit((event, detail) => debugRegistry.forWorld('(game)').emit(event, detail));
905
+ input.setDebugEmit((event, detail) => debugRegistry.forRoot('(game)').emit(event, detail));
751
906
  let inputMapPath: string | null = null;
752
907
  let inputMapLoad: Promise<void> | null = null;
753
908
 
754
- function requireDefaultWorld(): WorldInstance {
909
+ function requireDefaultRoot(): RootInstance {
755
910
  if (roots.length === 0) {
756
- throw new Error('Game.defaultWorld: no roots registered yet');
911
+ throw new Error('Game.defaultRoot: no roots registered yet');
757
912
  }
758
- return roots.find((w) => w.kind === 'threejs') ?? roots[0]!;
913
+ return roots.find((w) => w.kind === 'three') ?? roots[0]!;
759
914
  }
760
915
 
761
- function requireFirstPartyCtx(handle: 'components' | 'input' | 'audio') {
762
- const world = requireDefaultWorld();
916
+ function requireFirstPartyCtx(handle: 'input' | 'audio') {
917
+ const world = requireDefaultRoot();
763
918
  if (!isFirstPartyMounted(world.mounted)) {
764
919
  throw new Error(describeMismatch(handle, world));
765
920
  }
@@ -772,6 +927,7 @@ export function createGame(opts: {
772
927
  // short of a new `createGame` call, matching `reportedGateShortfalls`
773
928
  // above's "once per game instance" idiom.
774
929
  const warnedSystemAdapterKeys = new Set<string>();
930
+ const systemAdapterListeners = new Set<() => void>();
775
931
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
776
932
  function computeSystemAdapters(): SystemAdapters {
777
933
  // Debug is game-scoped: React hooks, probes, and the built-in time
@@ -780,17 +936,14 @@ export function createGame(opts: {
780
936
  // root may still publish this SAME adapter (the first-party Three/Pixi
781
937
  // mounts do); the reference-equality branch below treats that as the
782
938
  // intentional shared registration it is.
783
- const result: SystemAdapters = { debug: debugRegistry.adapter, input: inputAdapter };
784
- const ownerWorldId = new Map<string, string>([
785
- ['debug', '(game)'],
786
- ['input', '(game)'],
787
- ]);
939
+ const result: SystemAdapters = { debug: debugRegistry.adapter };
940
+ const ownerRootId = new Map<string, string>([['debug', '(game)']]);
788
941
  for (const world of roots) {
789
942
  const adapters = world.mounted.systems;
790
943
  if (!adapters) continue;
791
944
  for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
792
945
  if (adapters[key] === undefined) continue;
793
- const existingOwner = ownerWorldId.get(key);
946
+ const existingOwner = ownerRootId.get(key);
794
947
  if (existingOwner !== undefined) {
795
948
  // Reference-equality short-circuit: two roots sharing the ONE
796
949
  // game-scoped debug registry's adapter (T1.1) both expose the SAME
@@ -803,14 +956,14 @@ export function createGame(opts: {
803
956
  console.warn(
804
957
  `[game] systemAdapters: "${key}" is registered by both world "${existingOwner}" and ` +
805
958
  `world "${world.id}" — the FIRST registration ("${existingOwner}") wins; the later ` +
806
- 'one is shadowed (docs/GAME-ROOT-DESIGN.md §3: game-scoped system adapters).',
959
+ 'one is shadowed (game-scoped system adapters).',
807
960
  );
808
961
  }
809
962
  continue;
810
963
  }
811
964
  // biome-ignore lint/suspicious/noExplicitAny: SystemAdapters is a plain optional-field record; the per-key copy is correct by construction (same key on both sides), just not expressible without a cast
812
965
  (result as any)[key] = adapters[key];
813
- ownerWorldId.set(key, world.id);
966
+ ownerRootId.set(key, world.id);
814
967
  }
815
968
  }
816
969
  return result;
@@ -834,7 +987,7 @@ export function createGame(opts: {
834
987
  // Other native `console.warn`/`console.error` call sites in this file are
835
988
  // unsuppressed and already counted in the lint baseline (see `runFrame`'s
836
989
  // impl below); this one is a NEW site, so it's suppressed to keep this
837
- // task's diff at zero NEW warnings (same reasoning as `overlay-report.ts`'s
990
+ // task's diff at zero NEW warnings (same reasoning as `achieved-tier.ts`'s
838
991
  // identical suppression).
839
992
  // biome-ignore lint/suspicious/noConsole: see comment above
840
993
  console.warn(message);
@@ -845,7 +998,7 @@ export function createGame(opts: {
845
998
  * absent — shared by `pause()`/`resume()` below (same fan-out, opposite
846
999
  * boolean). */
847
1000
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fans out TWO independent capability gates (loop, audio) with the same "call it, else report once" shape per world — splitting the two gates into separate loops would duplicate the fan-out, not reduce real complexity
848
- function setWorldGates(next: boolean): void {
1001
+ function setRootGates(next: boolean): void {
849
1002
  for (const world of roots) {
850
1003
  if (!world.pausable) continue; // pausable:false roots are untouched by design
851
1004
  if (world.mounted.drivesOwnLoop) {
@@ -868,7 +1021,7 @@ export function createGame(opts: {
868
1021
  }
869
1022
  }
870
1023
 
871
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (GAME-ROOT-DESIGN §4, now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/roots — splitting it would obscure the ordering contract documented on GameInternal.runFrame
1024
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/roots — splitting it would obscure the ordering contract documented on GameInternal.runFrame
872
1025
  function runFrameImpl(
873
1026
  dt: number,
874
1027
  frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean; skipRenderPhases?: boolean },
@@ -882,11 +1035,14 @@ export function createGame(opts: {
882
1035
  if (rngTrapEnabled) rngTrap.enable();
883
1036
  profiler.beginFrame();
884
1037
  const ignorePause = frameOpts?.ignorePause ?? false;
885
- // D15/T-D15.4: internal-only render-phase skip (not part of the public
886
- // `GameInternal.runFrame` signatureno external caller sets it, mirroring
887
- // `onlyFrozen` below) — `runTicks`'s `render: 'none'|'last'` fast-forward
888
- // mode sets this on every tick it doesn't want to paint. See `runTicks`'s
889
- // doc comment on `GameInternal` for the full contract.
1038
+ // D15/T-D15.4 + WO-P13: the render-phase skip. Two callers set it, for the
1039
+ // same reasonthis substep is not the thing that paints. `runTicks`'s
1040
+ // `render: 'none'|'last'` fast-forward sets it on every tick it doesn't
1041
+ // want to paint (see `runTicks`'s doc comment on `GameInternal`), and a
1042
+ // DISPLAY-RATE host sets it on every substep because `runRenderFrame`
1043
+ // paints once per real frame instead. Unlike `onlyFrozen` below it is no
1044
+ // longer internal-only: it is part of the public `GameInternal.runFrame`
1045
+ // signature, since an external host is now one of those two callers.
890
1046
  const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
891
1047
  // `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
892
1048
  // signature — no external caller sets it) — `Game.play.step()` below is the
@@ -1069,7 +1225,7 @@ export function createGame(opts: {
1069
1225
  }
1070
1226
  }
1071
1227
 
1072
- // T7.4 slice 1 (REACT-STATE-BRIDGE.md §2): bump + notify LAST, after
1228
+ // T7.4 slice 1: bump + notify LAST, after
1073
1229
  // every phase of every world and every world's endFrame/update above —
1074
1230
  // subscribers must only ever observe post-frame state. Bumped at most
1075
1231
  // once per completed `runFrame`/`step()` call, never per phase/world —
@@ -1080,30 +1236,115 @@ export function createGame(opts: {
1080
1236
  stateBridge.bump();
1081
1237
  tick++;
1082
1238
  simT += dt;
1239
+ // P3 — sim timers fire IMMEDIATELY after the bump, inside this same
1240
+ // `advanced` guard: a paused/frozen world advances no sim time, so it
1241
+ // must fire no timers either, and `Game.play.step()` (which reaches this
1242
+ // block with `advanced` set by the frozen-set loop above) fires exactly
1243
+ // the timers that ONE substep makes due. One flush === one completed
1244
+ // substep, which is what lets `SimClock.tickNow()` simply count flushes
1245
+ // and always agree with `tick`.
1246
+ simClock.flush(simT);
1083
1247
  }
1084
1248
  profiler.endFrame();
1085
1249
  if (rngTrapEnabled) rngTrap.disable();
1086
1250
  }
1087
1251
 
1252
+ /**
1253
+ * WO-P13 — one display frame's PRESENTATION pass. See
1254
+ * `GameInternal.runRenderFrame`'s doc comment for the contract; this is the
1255
+ * `preRender`+`render` slice of `runFrameImpl`'s phase loop, lifted out and
1256
+ * driven by the loop's own per-real-frame callback instead of by the substep
1257
+ * loop.
1258
+ *
1259
+ * Three deliberate parallels with `runFrameImpl`, so the two passes cannot
1260
+ * drift into different rules for the same situation:
1261
+ * - game-scoped systems run before per-world hooks, per phase;
1262
+ * - one world's throw is logged and isolated, never allowed to starve a
1263
+ * sibling still due this phase;
1264
+ * - the RNG trap brackets the whole pass, so `Math.random` called from a
1265
+ * render-phase system is still caught by D15's dev-mode trap — it was,
1266
+ * back when this pass lived inside `runFrame`, and moving code must not
1267
+ * quietly move it out from under a guard.
1268
+ *
1269
+ * Profiler note (a real, accepted consequence): the profiler's "frame" has
1270
+ * always meant "one `runFrame` call", so a display frame now produces the
1271
+ * substep records it always did PLUS one record for this pass, carrying the
1272
+ * `preRender`/`render` spans and the renderer counters. Re-modelling the
1273
+ * profiler's frame boundary around the display frame is a separate change to
1274
+ * a debugging surface, not part of flipping the loop.
1275
+ */
1276
+ function runRenderFrameImpl(alpha: number, displayDt: number): void {
1277
+ renderAlpha = alpha;
1278
+ if (rngTrapEnabled) rngTrap.enable();
1279
+ profiler.beginFrame();
1280
+
1281
+ // `RenderStepped` first: a camera adjusted here is drawn by THIS frame's
1282
+ // render phase, not next frame's. Iterated over a snapshot so a callback
1283
+ // that unsubscribes itself (or registers another) cannot mutate the set
1284
+ // mid-iteration.
1285
+ if (renderStepCallbacks.size > 0) {
1286
+ for (const fn of [...renderStepCallbacks]) {
1287
+ try {
1288
+ fn(alpha, displayDt);
1289
+ } catch (err) {
1290
+ console.error('[game] an onRenderStep callback threw:', err);
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ const n = roots.length;
1296
+ for (const phase of DISPLAY_RATE_PHASES) {
1297
+ profiler.beginPhase();
1298
+ systems.runPhase(phase, displayDt);
1299
+ for (let i = 0; i < n; i++) {
1300
+ const world = roots[i]!;
1301
+ if (world.mounted.drivesOwnLoop) continue;
1302
+ // D10/T7.6, carried over verbatim in substance: a frozen world still
1303
+ // renders (a paused viewport stays painted) but with `dt` forced to
1304
+ // `0`, and skips every other phase.
1305
+ const frozen = paused && world.pausable;
1306
+ if (frozen && phase !== SystemPhase.RENDER) continue;
1307
+ try {
1308
+ world.frame?.runPhase(phase, frozen ? 0 : displayDt);
1309
+ } catch (err) {
1310
+ console.error(
1311
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
1312
+ '(display frame):',
1313
+ err,
1314
+ );
1315
+ }
1316
+ }
1317
+ profiler.endPhase(phase);
1318
+ }
1319
+
1320
+ profiler.endFrame();
1321
+ if (rngTrapEnabled) rngTrap.disable();
1322
+ }
1323
+
1088
1324
  const gameInternal: GameInternal = {
1089
1325
  loop: opts.loop,
1090
1326
  assets: opts.assets,
1327
+ playtest: opts.playtest ?? null,
1091
1328
  profiler,
1092
1329
  systems,
1093
1330
  get roots() {
1094
1331
  return roots;
1095
1332
  },
1096
- world(id: string): WorldInstance | null {
1333
+ world(id: string): RootInstance | null {
1097
1334
  return roots.find((w) => w.id === id) ?? null;
1098
1335
  },
1099
- get defaultWorld() {
1100
- return requireDefaultWorld();
1336
+ get defaultRoot() {
1337
+ return requireDefaultRoot();
1101
1338
  },
1102
1339
  get systemAdapters() {
1103
1340
  return computeSystemAdapters();
1104
1341
  },
1105
- get components() {
1106
- return requireFirstPartyCtx('components').components;
1342
+ subscribeSystemAdapters(listener: () => void) {
1343
+ systemAdapterListeners.add(listener);
1344
+ return () => systemAdapterListeners.delete(listener);
1345
+ },
1346
+ notifySystemAdaptersChanged() {
1347
+ for (const listener of systemAdapterListeners) listener();
1107
1348
  },
1108
1349
  input,
1109
1350
  get audio() {
@@ -1117,12 +1358,12 @@ export function createGame(opts: {
1117
1358
  pause() {
1118
1359
  if (paused) return;
1119
1360
  paused = true;
1120
- setWorldGates(true);
1361
+ setRootGates(true);
1121
1362
  },
1122
1363
  resume() {
1123
1364
  if (!paused) return;
1124
1365
  paused = false;
1125
- setWorldGates(false);
1366
+ setRootGates(false);
1126
1367
  },
1127
1368
  step(dt = 1 / 60) {
1128
1369
  // Self-driven pausable roots advance via their adapter's `step()`
@@ -1155,72 +1396,37 @@ export function createGame(opts: {
1155
1396
  runFrameImpl(dt, { onlyFrozen: true });
1156
1397
  },
1157
1398
  },
1158
- queryByComponent<T extends GameComponent<AdapterSurface>>(
1159
- cls: new () => T,
1160
- opts?: { worldId?: string; kind?: AdapterSurface },
1161
- ): T[] {
1162
- // Checklist item 8a: a `worldId` naming a world that doesn't exist is a
1163
- // caller error (typo'd id, wrong manifest) — degrade loudly, matching
1164
- // repo habit, rather than silently returning `[]`. A worldId that DOES
1165
- // exist but isn't first-party legitimately answers "no components"
1166
- // below (the loop's `!isFirstPartyMounted` `continue`), which stays
1167
- // silent — that's a real, not a mistaken, empty result.
1168
- if (opts?.worldId !== undefined && !roots.some((w) => w.id === opts.worldId)) {
1169
- throw new Error(`Game.queryByComponent: unknown worldId "${opts.worldId}"`);
1170
- }
1171
- const result: T[] = [];
1172
- for (const world of roots) {
1173
- // A world's manager comes from its first-party ctx OR from the
1174
- // optional `MountedWorldBase.components` capability (a non-first-
1175
- // party adapter running the engine's REAL ComponentManager — e.g.
1176
- // `@engine/world3d-react`'s R3F mount, whose `<Behavior>` components
1177
- // used to be invisible here: the query compiled, ran, and returned
1178
- // `[]` forever). A mount with NEITHER hosts no GameComponents, so
1179
- // its silent empty contribution is a real, not a mistaken, result.
1180
- const manager = isFirstPartyMounted(world.mounted)
1181
- ? world.mounted.ctx.components
1182
- : world.mounted.components;
1183
- if (!manager) continue;
1184
- if (opts?.worldId !== undefined && world.id !== opts.worldId) continue;
1185
- if (opts?.kind !== undefined && world.kind !== opts.kind) continue;
1186
- // Checklist item 8b: a plain for-loop push instead of
1187
- // `result.push(...arr)` — spread-as-arguments can hit engine/runtime
1188
- // argument-count limits once a world's instance count gets large.
1189
- const instances = manager.queryByComponent(cls);
1190
- for (const inst of instances) result.push(inst);
1191
- }
1192
- return result;
1193
- },
1194
- registerWorld(world: WorldInstance): void {
1399
+ registerRoot(world: RootInstance): void {
1195
1400
  if (roots.some((w) => w.id === world.id)) {
1196
- throw new Error(`Game.registerWorld: duplicate world id "${world.id}"`);
1401
+ throw new Error(`Game.registerRoot: duplicate world id "${world.id}"`);
1197
1402
  }
1198
1403
  // Checklist item 6: the SAME `mounted` object registered under two
1199
1404
  // world ids would be double-ticked by `runFrame` (its `frame.runPhase`/
1200
1405
  // `endFrame` called once per registration) and double-dispose-wrapped
1201
- // (`createWorldInstance` wraps `mounted.dispose` in place — a second
1406
+ // (`createRootInstance` wraps `mounted.dispose` in place — a second
1202
1407
  // wrap would flip `disposed` and call through on ITS OWN wrapped
1203
1408
  // `originalDispose`, which is harmless today only by accident of
1204
- // `VgaiSceneGameAdapter.dispose` being idempotent; a foreign adapter
1409
+ // `SetupThreeRootAdapter.dispose` being idempotent; a foreign adapter
1205
1410
  // has no such guarantee). Reject it outright instead.
1206
1411
  if (roots.some((w) => w.mounted === world.mounted)) {
1207
1412
  throw new Error(
1208
- `Game.registerWorld: world "${world.id}" shares its \`mounted\` object with an ` +
1413
+ `Game.registerRoot: world "${world.id}" shares its \`mounted\` object with an ` +
1209
1414
  `already-registered world ("${roots.find((w) => w.mounted === world.mounted)!.id}") — ` +
1210
1415
  'the same mount cannot be registered twice.',
1211
1416
  );
1212
1417
  }
1213
1418
  roots.push(world);
1419
+ gameInternal.notifySystemAdaptersChanged();
1214
1420
  // Notify state-bridge subscribers THE INSTANT the world list changes —
1215
1421
  // not just at the next completed `runFrame`. Root-cause fix for the
1216
- // "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI
1217
- // (docs/R3F-FIRST-PARTY-DESIGN.md W3, gate 4): `mountAllWorldSpecs`
1218
- // (`create-runtime.ts`) mounts roots SEQUENTIALLY, and a react world's
1219
- // `adapter.mount()` (`resolveDefaultReactAdapter`/`mountOneReactWorld`)
1220
- // renders its tree — synchronously in some React builds, but React 19's
1221
- // concurrent renderer does NOT guarantee a synchronous first commit
1222
- // (see `r3f-adapter.tsx`'s own doc comment on `onCreated`) — BEFORE the
1223
- // caller calls `registerWorld` for that very world. A `useGameState`
1422
+ // "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI (gate
1423
+ // 4): `mountAllRootSpecs` (`create-runtime.ts`) mounts roots
1424
+ // SEQUENTIALLY, and a react world's `adapter.mount()`
1425
+ // (`resolveDefaultReactAdapter`/`mountOneReactRoot`) renders its tree —
1426
+ // synchronously in some React builds, but React 19's concurrent
1427
+ // renderer does NOT guarantee a synchronous first commit (see
1428
+ // `r3f-adapter.tsx`'s own doc comment on `onCreated`) — BEFORE the
1429
+ // caller calls `registerRoot` for that very world. A `useWorldState`
1224
1430
  // selector reading `g.roots` can therefore render for the first time
1225
1431
  // while `roots` is still missing entries that register moments later.
1226
1432
  // Previously the ONLY way such a subscriber ever saw the corrected
@@ -1232,57 +1438,85 @@ export function createGame(opts: {
1232
1438
  // that first tick can be delayed well past a test's assertion window,
1233
1439
  // or — if the tab is ever backgrounded — not fire at all for a long
1234
1440
  // stretch; the subscriber's cached snapshot then sits on its stale
1235
- // (possibly fully empty) first render for that whole time, matching
1236
- // the observed CI symptom exactly ("the HUD element IS mounted; the
1237
- // roots list is EMPTY"). Proven red-then-green by
1238
- // `packages/engine/test/state-bridge.test.ts`'s "registerWorld notifies
1441
+ // (possibly fully empty) first render for that whole time, matching the
1442
+ // observed CI symptom exactly ("the HUD element IS mounted; the roots
1443
+ // list is EMPTY"). Proven red-then-green by
1444
+ // `packages/engine/test/state-bridge.test.ts`'s "registerRoot notifies
1239
1445
  // subscribers immediately" case: a subscriber registered before a
1240
1446
  // world, with `runFrame` NEVER called, only saw the update after this
1241
- // fix. `bump()` also advances `frameVersion` (the only invalidation
1242
- // key `createFrameSelectorCache`/`useGameState` understand — see
1447
+ // fix. `bump()` also advances `frameVersion` (the only invalidation key
1448
+ // `createFrameSelectorCache`/`useWorldState` understand — see
1243
1449
  // `frame-selector-cache.ts`), so this doubles as "frameVersion is
1244
1450
  // bumped once per completed runFrame OR once per world registered",
1245
1451
  // documented on `GameStateBridge.frameVersion` below.
1246
1452
  stateBridge.bump();
1247
- // T7.4 slice 2 (REACT-STATE-BRIDGE.md §4): a NON-first-party mount
1453
+ // T7.4 slice 2: a NON-first-party mount
1248
1454
  // (an ingested/foreign world — first-party mounts are exempt, they're
1249
- // observed via `Game.state`/`useGameState` instead) with no `observe`
1455
+ // observed via `Game.state`/`useWorldState` instead) with no `observe`
1250
1456
  // has no state bridge at all — react HUDs cannot subscribe to it, and
1251
1457
  // silently returning `undefined` forever would hide that. Report ONCE
1252
1458
  // per mount, at registration time, matching this file's existing
1253
1459
  // `[game] world "<id>" (kind: <kind>) ...` console idiom (see
1254
1460
  // `runFrame` below).
1255
1461
  //
1256
- // §7.1-15: a `kind: 'react'` world is ALSO exempt — it has no `observe`
1257
- // BY DESIGN (D8/REACT-WORLD-DESIGN.md: a react world's own mounted tree
1258
- // reads state via `Game.state`/`useGameState`, the SAME first-party
1259
- // bridge a threejs/pixijs world's HUD uses, never `WorldStateObserver`
1462
+ // §7.1-15: a `kind: 'dom'` world is ALSO exempt — it has no `observe`
1463
+ // BY DESIGN (D8: a react world's own mounted tree
1464
+ // reads state via `Game.state`/`useWorldState`, the SAME first-party
1465
+ // bridge a three/canvas world's HUD uses, never `RootStateObserver`
1260
1466
  // — that hook is scoped to the ingested/foreign-world case). Without
1261
1467
  // this exemption every production react world logged a false-positive
1262
1468
  // "no state bridge" warning at registration (probe: every real react
1263
1469
  // world mount), eroding the signal for a genuinely un-observable
1264
1470
  // ingested world.
1265
- // A mount exposing the engine's real ComponentManager (notably the R3F
1266
- // first-party adapter) is observable through Game.queryByComponent +
1267
- // Game.state even though it does not carry the classic scene adapter's
1268
- // `firstParty` brand. Requiring a foreign-world `observe` bridge there
1269
- // produces a false warning while the canonical bridge is already live.
1471
+ // A HOST-DRIVEN mount (`drivesOwnLoop: false`, notably the R3F adapter)
1472
+ // is observable through `Game.state` even though it does not carry the
1473
+ // classic scene adapter's `firstParty` brand: it ticks inside
1474
+ // `runFrame`, so the frame-versioned bridge covers it. Requiring a
1475
+ // foreign-world `observe` bridge there produces a false warning while
1476
+ // the canonical bridge is already live.
1270
1477
  if (
1271
1478
  !isFirstPartyMounted(world.mounted) &&
1272
- !world.mounted.components &&
1273
- world.kind !== 'react' &&
1479
+ world.mounted.drivesOwnLoop &&
1480
+ world.kind !== 'dom' &&
1274
1481
  !world.mounted.observe
1275
1482
  ) {
1276
1483
  console.warn(
1277
1484
  `[game] world "${world.id}" (kind: ${world.kind}, adapter: "${world.adapter.id}"): ` +
1278
- 'no state bridge — this mounted game has no `observe` (WorldStateObserver); ' +
1279
- 'react HUDs/useWorldObservation cannot subscribe to its state (docs/REACT-STATE-BRIDGE.md §4).',
1485
+ 'no state bridge — this mounted game has no `observe` (RootStateObserver); ' +
1486
+ 'react HUDs/useRootObservation cannot subscribe to its state.',
1280
1487
  );
1281
1488
  }
1282
1489
  },
1283
- runFrame(dt: number, frameOpts?: { ignorePause?: boolean }): void {
1490
+ runFrame(dt: number, frameOpts?: { ignorePause?: boolean; skipRenderPhases?: boolean }): void {
1284
1491
  runFrameImpl(dt, frameOpts);
1285
1492
  },
1493
+ runRenderFrame(alpha: number, displayDt: number): void {
1494
+ runRenderFrameImpl(alpha, displayDt);
1495
+ },
1496
+ get renderAlpha() {
1497
+ return renderAlpha;
1498
+ },
1499
+ onRenderStep(
1500
+ fn: (alpha: number, displayDt: number) => void,
1501
+ stepOpts?: { signal?: AbortSignal | undefined },
1502
+ ): () => void {
1503
+ const signal = stepOpts?.signal;
1504
+ // Already-aborted is a no-op registration, never a throw — the same
1505
+ // `AbortSignal` contract `SimClock.after` honors.
1506
+ if (signal?.aborted) return () => {};
1507
+ renderStepCallbacks.add(fn);
1508
+ let detachAbort: (() => void) | null = null;
1509
+ const unsubscribe = (): void => {
1510
+ renderStepCallbacks.delete(fn);
1511
+ detachAbort?.();
1512
+ detachAbort = null;
1513
+ };
1514
+ if (signal) {
1515
+ signal.addEventListener('abort', unsubscribe, { once: true });
1516
+ detachAbort = (): void => signal.removeEventListener('abort', unsubscribe);
1517
+ }
1518
+ return unsubscribe;
1519
+ },
1286
1520
  runTicks(n: number, ticksOpts?: RunTicksOptions): void {
1287
1521
  if (!Number.isInteger(n) || n < 0) {
1288
1522
  throw new RangeError(`Game.runTicks: n must be a non-negative integer, got ${n}`);
@@ -1294,7 +1528,7 @@ export function createGame(opts: {
1294
1528
  throw new DebugError(
1295
1529
  'RUN_TICKS_PAUSED',
1296
1530
  'Game.runTicks: refused — Game.play.paused is true; stepping the frozen set is ' +
1297
- "Game.play.step()'s contract, not runTicks' (docs/D15-DETERMINISM-DESIGN.md §2.b)",
1531
+ "Game.play.step()'s contract, not runTicks'",
1298
1532
  );
1299
1533
  }
1300
1534
  const render = ticksOpts?.render ?? 'last';
@@ -1317,6 +1551,18 @@ export function createGame(opts: {
1317
1551
  return inputMapLoad;
1318
1552
  },
1319
1553
  dispose(): void {
1554
+ // P3 — the sim clock is GAME-scoped, so this is the only correct place to
1555
+ // dispose it: `create-runtime.ts`'s `fullCleanup` calls us after EVERY
1556
+ // root's `mounted.dispose()`, whereas a per-root teardown may be ending
1557
+ // just one sub-session while sibling roots keep running (see the
1558
+ // disposed-world guard in `runFrame`). It used to be disposed from
1559
+ // `setup-three-root-adapter.ts`'s teardown, which meant disposing one of
1560
+ // two three roots froze `now()` for the whole Game, rejected the other
1561
+ // world's pending `delay`s and turned its `after()` calls into silent
1562
+ // no-ops. Note the asymmetry that gives the bug away: `seededRandom` and
1563
+ // `debugRegistry` are game-scoped too, and per-root teardown has never
1564
+ // destroyed either — only ever `strip(this.id)`, its own slice.
1565
+ simClock.dispose();
1320
1566
  input.dispose();
1321
1567
  debugRegistry.strip();
1322
1568
  },
@@ -1339,6 +1585,9 @@ export function createGame(opts: {
1339
1585
  // `createGame` returns.
1340
1586
  registerSeededRandom(gameInternal, seededRandom);
1341
1587
  registerGameplayRngTrapControl(gameInternal, rngTrapControl);
1588
+ // P3 — same "file after the shell exists" ordering: `getSimClock(game)` is
1589
+ // how every world's mount resolves `ctx.clock` to THIS game's one clock.
1590
+ registerSimClock(gameInternal, simClock);
1342
1591
 
1343
1592
  return gameInternal;
1344
1593
  }