@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,52 +1,47 @@
1
1
  import * as THREE from 'three';
2
2
  import { assertNever } from '../adapter/adapter-surface';
3
+ import type { CanvasHostContext, DomHostContext, ThreeHostContext } from '../adapter/host-context';
3
4
  import type {
4
- GameAdapter,
5
- MountedGame,
6
- MountedReactWorld,
7
- MountedWorld,
8
- } from '../adapter/game-adapter';
9
- import type { HostContext, LoopHandle } from '../adapter/host-context';
5
+ MountedPixiRoot,
6
+ MountedReactRoot,
7
+ MountedRoot,
8
+ MountedThreeRoot,
9
+ RootAdapter,
10
+ } from '../adapter/root-adapter';
10
11
  import { createAssetCache } from '../assets';
11
12
  import { createGameLoop } from '../core/game-loop';
12
13
  import { createHostRenderer } from '../setup/setup-renderer';
13
- // TYPE-ONLY (same rule `runtime/game.ts` documents for its own pixi/world2d
14
- // imports): `create-runtime.ts` must never value-import `pixi.js` or
15
- // `world2d/pixi-game-adapter.ts` a pixijs `WorldMountSpec`'s `adapter` is
16
- // supplied ALREADY-CONSTRUCTED by the caller (a `PixiSceneGameAdapter` or any
17
- // structurally-compatible adapter), so the host only ever needs these types.
18
- import type { MountedGame2D, World2DHost } from '../world2d/pixi-game-adapter';
14
+ // TYPE-ONLY: `create-runtime.ts` must never value-import `pixi.js` — a canvas
15
+ // `RootMountSpec`'s `adapter` is supplied ALREADY-CONSTRUCTED by the caller, so
16
+ // the host only ever needs the seam's types.
19
17
  import {
20
18
  createGame,
21
- createWorldInstance,
19
+ createRootInstance,
22
20
  type Game,
23
21
  type GameInternal,
24
22
  isFirstPartyMounted,
25
- type WorldInstance,
23
+ type RootInstance,
26
24
  } from './game';
27
25
  import { createInputRouter, type RouterAdapterRoot, stackOrder } from './input-router';
28
26
  import { installRenderControlHarness, isRenderModeRequested } from './render-control';
29
-
30
- // Re-export the camera-precedence helper from its new home so existing importers
31
- // (`import { adoptSceneCamera } from '@engine/runtime/create-runtime'`) keep working.
32
- export { adoptSceneCamera } from '../adapter/vgai-scene-game-adapter';
27
+ import type { PlaytestContext } from './types';
33
28
 
34
29
  /**
35
- * Register a threejs world for a freshly-mounted game onto the Game shell.
36
- * Shared by `createGameRuntime` and every headless test harness
30
+ * Register a three world for a freshly-mounted game onto the Game shell. Shared
31
+ * by `createGameRuntime` and every headless test harness
37
32
  * (`test/game-root.test.ts`, `test/frame-order.test.ts`,
38
33
  * `test/game-two-roots.test.ts`) so there is exactly one code path for this
39
- * wiring (T7.1 slice 1, id-generalized in slice 3 for the two-world proof —
40
- * `docs/GAME-ROOT-DESIGN.md` §8 stage 3) — `physics`/`collisions`/`camera`/
41
- * `frame` are populated from the mount's first-party `GameContext`/
42
- * `VgaiMountedGame` when available, left `undefined` otherwise (an external
43
- * adapter's mount has none of these first-party handles — `frame` in
44
- * particular is what makes `GameInternal.runFrame` (T7.1 slice 2) fall back
45
- * to a single opaque `mounted.update` call per substep for such a world).
34
+ * wiring (T7.1 slice 1, id-generalized in slice 3 for the two-world proof — stage
35
+ * 3) — `physics`/`collisions`/`camera`/ `frame` are populated from the mount's
36
+ * first-party `GameContext`/ `MountedSetupThreeRoot` when available, left
37
+ * `undefined` otherwise (an external adapter's mount has none of these
38
+ * first-party handles — `frame` in particular is what makes
39
+ * `GameInternal.runFrame` (T7.1 slice 2) fall back to a single opaque
40
+ * `mounted.update` call per substep for such a world).
46
41
  */
47
42
  function firstPartyExtras(
48
- mounted: MountedWorld,
49
- ): Pick<Parameters<typeof createWorldInstance>[0], 'physics' | 'collisions' | 'camera' | 'frame'> {
43
+ mounted: MountedRoot,
44
+ ): Pick<Parameters<typeof createRootInstance>[0], 'physics' | 'collisions' | 'camera' | 'frame'> {
50
45
  if (!isFirstPartyMounted(mounted)) return {};
51
46
  return {
52
47
  physics: mounted.ctx.physics,
@@ -57,192 +52,122 @@ function firstPartyExtras(
57
52
  }
58
53
 
59
54
  /**
60
- * Register a threejs world onto the Game shell. `opts.id` defaults to
55
+ * Register a three world onto the Game shell. `opts.id` defaults to
61
56
  * `'main'` — the single-world callers (`createGameRuntime`, slice-1/2 tests)
62
57
  * are unaffected; a second/third world (T7.1 slice 3's two-world proof, and
63
58
  * real multi-world manifests later) pass an explicit id. This is the ONE
64
- * registration code path for a threejs world, whatever its id.
59
+ * registration code path for a three world, whatever its id.
65
60
  */
66
- export function registerThreeWorld(
61
+ export function registerThreeRoot(
67
62
  game: GameInternal,
68
- adapter: GameAdapter,
69
- mounted: MountedGame,
63
+ adapter: RootAdapter,
64
+ mounted: MountedThreeRoot,
70
65
  opts?: { id?: string | undefined; pausable?: boolean | undefined },
71
- ): WorldInstance {
72
- const world = createWorldInstance({
66
+ ): RootInstance {
67
+ const world = createRootInstance({
73
68
  id: opts?.id ?? 'main',
74
- kind: 'threejs',
69
+ kind: 'three',
75
70
  pausable: opts?.pausable ?? true,
76
71
  adapter,
77
72
  mounted,
78
73
  scene: mounted.scene as THREE.Scene,
79
74
  ...firstPartyExtras(mounted),
80
75
  });
81
- // T7.2 slice 2 — one-time world-wiring backfill (closes the KNOWN GAP
82
- // documented on `resolveWorldInstance` in `ecs/component-manager.ts`):
83
- // scene-authored components were attached by the scene loader DURING the
84
- // `mount()` call that just returned `mounted`, before this `WorldInstance`
85
- // existed — so `instance.world` was left `undefined` for every one of
86
- // them. `adoptWorld` backfills it now, once, for every already-attached
87
- // instance still missing a world. Attaches made AFTER this point (e.g.
88
- // runtime-spawned entities once the game is ticking) still resolve their
89
- // world directly at attach time, unaffected by this call.
90
- if (isFirstPartyMounted(mounted)) {
91
- mounted.ctx.components.adoptWorld(world);
92
- } else if (mounted.components) {
93
- // Same one-time backfill for a non-first-party mount exposing the
94
- // optional `MountedWorldBase.components` capability (e.g.
95
- // `@engine/world3d-react` — its `<Behavior>` attaches happened during
96
- // `mount()`, before this `WorldInstance` existed).
97
- mounted.components.adoptWorld(world);
98
- }
99
- game.registerWorld(world);
76
+ game.registerRoot(world);
100
77
  return world;
101
78
  }
102
79
 
103
80
  /**
104
- * Register a pixijs world onto the Game shell — the pixi analog of
105
- * {@link registerThreeWorld}, mirroring the wiring
106
- * `world2d/pixi-game-adapter.ts`'s `createWorld2DRuntime` performs for its
107
- * OWN (separate) Game, and `test/game-three-plus-pixi.test.ts` performs by
108
- * hand onto a SHARED Game. `createGameRuntime`'s roots path
109
- * (T6.1 slice 1) is the first PRODUCTION caller that registers a pixijs
110
- * world alongside other roots on one Game; this is its one wiring code
111
- * path, so a second/third pixijs world (or a future caller) never
112
- * re-derives it. `adapter` is typed as the real {@link Pixi2DGameAdapter}
113
- * shape (T7.5 — previously the generic `GameAdapter`, which forced a cast at
114
- * every call site since a pixi adapter's `mount` takes a `World2DHost`, not
115
- * `HostContext`); `WorldInstance.adapter` itself only needs `.id`
81
+ * Register a canvas world onto the Game shell — the pixi analog of
82
+ * {@link registerThreeRoot}. `adapter` is the seam's own
83
+ * `RootAdapter<'canvas'>`; `RootInstance.adapter` itself only needs `.id`
116
84
  * (`AdapterHandle`, `runtime/game.ts`), so this passes through with zero cast.
117
- *
118
- * Section 7.4-5 (design-gap catalog), UPDATED by wave 2 (D-Y1,
119
- * docs/EXTERNAL-REACT-AND-MODULE-DESIGN.md §1/§4 slice S1): this guard used
120
- * to THROW for a non-first-party mount, reasoning "today this can never
121
- * actually fire (Pixi2DGameAdapter.mount is typed to always return a
122
- * MountedGame2D, whose ctx/physics2d/collisions2d/frame are all REQUIRED
123
- * fields, so there is no foreign-mount shape that could reach here)". Wave 2
124
- * proved that reasoning stale: a `kind: 'pixijs'` `{ module }` adapter's mount
125
- * result IS exactly such a foreign shape — `Pixi2DGameAdapter`'s TYPE promises
126
- * `MountedGame2D`, but `adapter-resolver.ts`'s `resolveAllWorlds` reaches it
127
- * only via `adapter as unknown as Pixi2DGameAdapter` (a custom module's actual
128
- * return value is merely `MountedPixiWorld`-shaped, T7.5's cross-kind union),
129
- * so the "never actually fire" premise no longer holds the moment a custom
130
- * pixi module world reaches the roots path. Rather than keep the throw (which
131
- * would re-create exactly the kind of dead end D-X6 named and D-Y1 exists to
132
- * remove), this now degrades EXACTLY like `registerThreeWorld`'s own foreign-
133
- * mount handling above (`firstPartyExtras`): `physics2d`/`collisions2d`/
134
- * `frame`/component-adoption are omitted for a non-first-party mount instead
135
- * of read from a `.ctx` that doesn't exist — `stage` (the one field every
136
- * `MountedPixiWorld`, first-party or not, actually carries) is always real.
85
+ * `stage` is the one field every `MountedPixiRoot` actually carries.
137
86
  */
138
- function pixiFirstPartyExtras(
139
- mounted: MountedGame2D,
140
- ): Pick<Parameters<typeof createWorldInstance>[0], 'physics2d' | 'collisions2d' | 'frame'> {
141
- // Same structural check `firstPartyExtras`/`isFirstPartyMounted` use — see
142
- // this function's own doc comment (above `registerPixiWorld`) for why this
143
- // stays a plain boolean read rather than a type-guarded `isFirstPartyMounted`
144
- // call (that guard's threejs-shaped return type would narrow `mounted` to
145
- // `never` here).
146
- if ((mounted as { firstParty?: unknown }).firstParty !== true) return {};
147
- return {
148
- physics2d: mounted.ctx.physics2d,
149
- collisions2d: mounted.ctx.collisions2d,
150
- frame: mounted.frame,
151
- };
152
- }
153
-
154
- export function registerPixiWorld(
87
+ export function registerPixiRoot(
155
88
  game: GameInternal,
156
- adapter: Pixi2DGameAdapter,
157
- mounted: MountedGame2D,
89
+ adapter: RootAdapter<'canvas'>,
90
+ mounted: MountedPixiRoot,
158
91
  opts?: { id?: string | undefined; pausable?: boolean | undefined },
159
- ): WorldInstance {
92
+ ): RootInstance {
160
93
  const id = opts?.id ?? 'main';
161
- const mountedIsFirstParty = (mounted as { firstParty?: unknown }).firstParty === true;
162
- const world = createWorldInstance({
94
+ const world = createRootInstance({
163
95
  id,
164
- kind: 'pixijs',
96
+ kind: 'canvas',
165
97
  pausable: opts?.pausable ?? true,
166
98
  adapter,
167
99
  mounted,
168
100
  stage: mounted.stage,
169
- ...pixiFirstPartyExtras(mounted),
170
101
  });
171
- // Mirrors `registerThreeWorld`'s adoptWorld backfill above — the pixi
172
- // world's scene-authored components attach during `mount()`, before this
173
- // `WorldInstance` exists. Skipped for a non-first-party mount (a foreign
174
- // `{ module }` adapter's `ComponentManager` doesn't exist to adopt into —
175
- // same "nothing for a custom adapter to backfill" reasoning
176
- // `registerReactWorld`'s doc comment records for react).
177
- if (mountedIsFirstParty) mounted.ctx.components.adoptWorld(world);
178
- game.registerWorld(world);
102
+ game.registerRoot(world);
179
103
  return world;
180
104
  }
181
105
 
182
106
  /**
183
- * Register a react world onto the Game shell (T6.2 slice 1,
184
- * `docs/REACT-WORLD-DESIGN.md` §1.B) the react analog of
185
- * {@link registerThreeWorld}/{@link registerPixiWorld}. A react world has no
186
- * `ComponentManager` to adopt (D8: react entities host no GameComponents —
187
- * there is nothing for `adoptWorld` to backfill, unlike the three/pixi
188
- * siblings above) and no `frame` hooks (react's own `createRoot` schedules
189
- * its commits; `GameInternal.runFrame` correctly leaves a world with no
190
- * `frame` untouched by its opaque-`update` fallback too, since
191
- * `MountedReactGame` declares no `update`). `adapter` is typed as the real
192
- * {@link ReactRootAdapter} shape (T7.5, same reasoning as
193
- * `registerPixiWorld`'s doc comment above) — `WorldInstance.adapter` only
107
+ * Register a react world onto the Game shell (T6.2 slice 1) — the react
108
+ * analog of {@link registerThreeRoot}/{@link registerPixiRoot}. A react
109
+ * world has no `frame` hooks (react's own
110
+ * `createRoot` schedules its commits; `GameInternal.runFrame` correctly
111
+ * leaves a world with no `frame` untouched by its opaque-`update` fallback
112
+ * too, since `MountedReactGame` declares no `update`). `adapter` is typed
113
+ * as the real {@link ReactRootAdapter} shape (T7.5, same reasoning as
114
+ * `registerPixiRoot`'s doc comment above) `RootInstance.adapter` only
194
115
  * needs `.id`, so this passes through with zero cast.
195
116
  */
196
- export function registerReactWorld(
117
+ export function registerReactRoot(
197
118
  game: GameInternal,
198
- adapter: ReactRootAdapter,
199
- mounted: MountedReactGame,
119
+ adapter: RootAdapter<'dom'>,
120
+ mounted: MountedReactRoot,
200
121
  container: HTMLElement,
201
122
  opts?: { id?: string | undefined; pausable?: boolean | undefined },
202
- ): WorldInstance {
203
- const world = createWorldInstance({
123
+ ): RootInstance {
124
+ const world = createRootInstance({
204
125
  id: opts?.id ?? 'main',
205
- kind: 'react',
126
+ kind: 'dom',
206
127
  pausable: opts?.pausable ?? true,
207
128
  adapter,
208
129
  mounted,
209
130
  container,
210
131
  });
211
- game.registerWorld(world);
132
+ game.registerRoot(world);
212
133
  return world;
213
134
  }
214
135
 
215
136
  /**
216
- * A pixijs-shaped adapter the structural contract `WorldMountSpec`'s
217
- * `pixijs` variant requires. `PixiSceneGameAdapter` (`world2d/
218
- * pixi-game-adapter.ts`) satisfies this today; it is deliberately NOT named
219
- * here as a concrete type so an external pixi adapter can satisfy the same
220
- * shape without importing the first-party implementer.
221
- */
222
- export interface Pixi2DGameAdapter {
223
- readonly id: string;
224
- mount(host: World2DHost): Promise<MountedGame2D>;
225
- }
226
-
227
- /**
228
- * One world to mount in the {@link WorldsRuntimeConfig} roots path (T6.1
137
+ * One world to mount in the {@link RootsRuntimeConfig} roots path (T6.1
229
138
  * slice 1) — the host-facing mirror of `manifest/load.ts`'s
230
139
  * `ResolvedAdapterRoot` (same `id`/`zOrder`/`pausable`/`loop` fields; the
231
140
  * manifest-to-host translation itself is the editor's job, not this file's).
232
141
  *
233
- * `react` (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md`) mounts as a DOM-root
234
- * layer `<div>` in the SAME stack instead of a canvas — see
235
- * {@link ReactWorldMountSpec}/{@link ReactRootAdapter} below.
142
+ * `react` (T6.2 slice 1) mounts as a DOM-root layer `<div>` in the SAME
143
+ * stack instead of a canvas — see {@link ReactRootMountSpec}/{@link
144
+ * ReactRootAdapter} below.
236
145
  */
237
- export interface WorldMountSpecBase {
146
+ export interface RootMountSpecBase {
238
147
  /** Manifest id — must be unique within one `roots` array. */
239
148
  readonly id: string;
240
- /** Canvas stacking order (COMPOSITION-DESIGN D5 §1); ties broken by array
149
+ /** Canvas stacking order (D5 §1); ties broken by array
241
150
  * order, mirroring `manifest/load.ts`'s `loadGameManifest` sort. Defaults
242
151
  * to `0`. */
243
152
  readonly zOrder?: number | undefined;
244
153
  /** Whether play-mode pause/step applies to this world (D10). Defaults to `true`. */
245
154
  readonly pausable?: boolean | undefined;
155
+ /**
156
+ * `vgai.project.json`'s `dev: true` — this root is a DEV LAYER (the game's
157
+ * own dev GUI), not shipped game content. Two things happen here: the
158
+ * surface is stacked ABOVE every non-dev root (a dev layer sits topmost),
159
+ * and it is marked `data-vgai-dev-layer="true"` so the play compositor can
160
+ * leave it out of a capture (`composite-screenshot.ts`). Whether a dev root
161
+ * reaches this function at all is the CALLER's gate —
162
+ * `mountManifestRoots` asks `devLayersEnabled()` before building the spec.
163
+ *
164
+ * It steals no input from the game while closed for a reason that predates
165
+ * this flag and needs no new mechanism: a dev layer is a `dom` root, DOM
166
+ * layers are `pointer-events:none` (see `mountOneReactRoot`) with the
167
+ * mounted tree opting individual elements back in, and the input router
168
+ * never sees DOM roots at all.
169
+ */
170
+ readonly dev?: boolean | undefined;
246
171
  /** `'gated'` (host-driven, default) or `'self-driven'` (this world drives
247
172
  * its own loop — D5's "composited, unsynchronized" tier). Carried through
248
173
  * for parity with `ResolvedAdapterRoot`; T6.1 slice 1 does not yet
@@ -253,133 +178,104 @@ export interface WorldMountSpecBase {
253
178
  * Optional claim predicate for the delegating input router (D5 §2a), over
254
179
  * a point RELATIVE TO THE CONTAINER. Absent means: this world claims only
255
180
  * if it ends up the bottom (lowest zOrder) world — see
256
- * `input-router.ts`'s `resolveClaimingWorld`. A pixijs world with no
257
- * explicit `hitTest` gets a REAL default derived from its own mounted
258
- * stage's `EventBoundary.hitTest` (see `derivePixiHitTest` below) — a
259
- * threejs world with no explicit `hitTest` gets no default (three has no
260
- * cheap universal "is this pixel interactive" answer).
181
+ * `input-router.ts`'s `resolveClaimingRoot`.
261
182
  */
262
183
  readonly hitTest?: ((x: number, y: number) => boolean) | undefined;
263
184
  }
264
185
 
265
- export interface ThreeWorldMountSpec extends WorldMountSpecBase {
266
- readonly kind: 'threejs';
267
- readonly adapter: GameAdapter;
268
- }
269
-
270
- export interface PixiWorldMountSpec extends WorldMountSpecBase {
271
- readonly kind: 'pixijs';
272
- readonly adapter: Pixi2DGameAdapter;
186
+ export interface ThreeRootMountSpec extends RootMountSpecBase {
187
+ readonly kind: 'three';
188
+ readonly adapter: RootAdapter;
273
189
  }
274
190
 
275
191
  /**
276
- * The host-facing surface handed to a {@link ReactRootAdapter}'s `mount`
277
- * (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md` §1.B) the react analog of
278
- * {@link World2DHost}. `container` is the absolutely-positioned, z-ordered
279
- * DOM-root layer `<div>` the host already created and stacked (same box/
280
- * z-order rules as a canvas per COMPOSITION-DESIGN D5 §1) — the adapter's
281
- * `mount` renders its react tree INTO this exact element via `createRoot`;
282
- * it must never create its own root element (mirrors `HostContext`'s
283
- * "the world's runtime renders into the surface it is handed" rule for
284
- * canvases, `docs/GAME-ROOT-DESIGN.md` §2).
192
+ * `adapter` is the SEAM's `RootAdapter<'canvas'>` a canvas root's mount
193
+ * honestly returns only a `MountedPixiRoot`, and naming the real contract
194
+ * here is what keeps the resolver cast-free.
285
195
  */
286
- export interface ReactWorldHost {
287
- readonly container: HTMLElement;
288
- /**
289
- * The Game this world is being mounted into — symmetric with
290
- * `HostContext.game`; what a react adapter hands to `<GameProvider>` so
291
- * `useGameState` selectors read live state (T7.4 bridge).
292
- *
293
- * Optional as of D-V3 (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md, F24
294
- * composite): a `default-react` sibling mounted BESIDE an ingest world
295
- * (`packages/editor/src/ingest-siblings.ts`) has no native `Game` to hand
296
- * it — there is no first-party `GameContext`/loop for the sibling to join,
297
- * only the ingested world's own foreign runtime — so its host carries no
298
- * `game` at all rather than fabricating an empty one (anti-shim rule); the
299
- * sibling mounts its entry component bare, with no `<GameProvider>` wrap.
300
- * `mountOneReactWorld` below (the NATIVE multi-world runtime path) still
301
- * ALWAYS supplies a real `game` — this optionality is reached only by the
302
- * composite sibling's own hand-built host, never by weakening the native
303
- * path's guarantee.
304
- */
305
- readonly game?: Game;
196
+ export interface PixiRootMountSpec extends RootMountSpecBase {
197
+ readonly kind: 'canvas';
198
+ readonly adapter: RootAdapter<'canvas'>;
306
199
  }
307
200
 
308
201
  /**
309
- * A live, mounted react world (T6.2 slice 1) — the react analog of
310
- * {@link MountedGame2D}. React roots host no ticking components (D8
311
- * `ecs/component-manager.ts` already throws on any attach to a react-kind
312
- * manager) and render from game state via the T7.4 bridge instead of a
313
- * per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
202
+ * A live, mounted react world (T6.2 slice 1) — the react analog of a mounted
203
+ * canvas world. React roots render from game state via the T7.4 bridge
204
+ * instead of a per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
314
205
  * `ctx`/`frame` — `drivesOwnLoop` is always `false` (react's `createRoot`
315
206
  * schedules its OWN commits; the host's fixed-step loop never drives it,
316
207
  * and it is correctly skipped by `GameInternal.runFrame`'s per-world
317
- * `frame`-hooks/opaque-`update` dispatch — see `registerReactWorld` below,
318
- * which registers this `WorldInstance` with no `frame`, same as any other
208
+ * `frame`-hooks/opaque-`update` dispatch — see `registerReactRoot` below,
209
+ * which registers this `RootInstance` with no `frame`, same as any other
319
210
  * opaque mount with nothing to tick).
320
211
  *
321
212
  * Deliberately carries NO `firstParty: true` brand: that brand specifically
322
213
  * means "has a `.ctx: GameContext`" (`isFirstPartyMounted`,
323
214
  * `runtime/game.ts`) — a react world has no Rapier/ComponentManager/
324
215
  * GameContext at all, so branding it first-party would be a type lie.
325
- * `Game.registerWorld`'s "no state bridge" console warning
326
- * (`docs/REACT-STATE-BRIDGE.md` §4) explicitly exempts `kind: 'react'`
327
- * (§7.1-15): a react world has no `observe` BY DESIGN — that hook is scoped
328
- * to `useWorldObservation` (the ingested/foreign-world case), not
329
- * `useGameState` (`ui/game-state.tsx`), which a react world's own mounted
330
- * tree uses instead — it reads `Game.state` directly (T7.4's actual bridge
331
- * for first-party-observable state), never a per-world `observe`.
216
+ * `Game.registerRoot`'s "no state bridge" console warning explicitly exempts
217
+ * `kind: 'dom'` (§7.1-15): a react world has no `observe` BY DESIGN — that
218
+ * hook is scoped to `useRootObservation` (the ingested/foreign-world case),
219
+ * not `useWorldState` (`ui/game-state.tsx`), which a react world's own
220
+ * mounted tree uses instead — it reads `Game.state` directly (T7.4's actual
221
+ * bridge for first-party-observable state), never a per-world `observe`.
332
222
  *
333
- * `kind`/`container` (T7.5) satisfy `MountedReactWorld` (`adapter/
334
- * game-adapter.ts`) — `container` is the SAME `ReactWorldHost.container` the
335
- * adapter's `mount` was handed (identity matters, mirroring `threeRoot()`/
336
- * `pixiRoot()`'s "same instance the adapter mounted" contract); every
223
+ * `kind`/`container` (T7.5) satisfy `MountedReactRoot` (`adapter/
224
+ * root-adapter.ts`) — `container` is the SAME `DomHostContext.container` the
225
+ * adapter's `mount` was handed (identity matters, mirroring `threeScene()`/
226
+ * `pixiStage()`'s "same instance the adapter mounted" contract); every
337
227
  * `ReactRootAdapter` implementer echoes it back here so `mounted` alone
338
228
  * (with no separately-threaded `container`) satisfies the union
339
- * `WorldInstanceInit.mounted`/`WorldInstance.mounted` with zero cast.
229
+ * `RootInstanceInit.mounted`/`RootInstance.mounted` with zero cast.
340
230
  */
341
- export interface MountedReactGame extends MountedReactWorld {
231
+ export interface MountedReactGame extends MountedReactRoot {
342
232
  readonly drivesOwnLoop: false;
343
233
  }
344
234
 
345
235
  /**
346
- * A react-shaped adapter — the structural contract `WorldMountSpec`'s
347
- * `react` variant requires. Mirrors {@link Pixi2DGameAdapter}: deliberately
236
+ * A react-shaped adapter — the structural contract `RootMountSpec`'s
237
+ * `react` variant requires. Deliberately
348
238
  * NOT tied to a concrete implementer here so an editor-resolved adapter (the
349
239
  * `default-react` resolver branch, T6.2's editor-side follow-up) can satisfy
350
240
  * this shape without this file importing react-dom or any editor code.
241
+ *
242
+ * A genuine `RootAdapter<'dom'>` refinement (`HostContextFor<'dom'>` =
243
+ * `DomHostContext` = {@link DomHostContext}), narrowing only the return type to
244
+ * {@link MountedReactGame}.
351
245
  */
352
- export interface ReactRootAdapter {
246
+ export interface ReactRootAdapter extends RootAdapter<'dom'> {
353
247
  readonly id: string;
354
- mount(host: ReactWorldHost): Promise<MountedReactGame>;
248
+ mount(host: DomHostContext): Promise<MountedReactGame>;
355
249
  }
356
250
 
357
- export interface ReactWorldMountSpec extends WorldMountSpecBase {
358
- readonly kind: 'react';
359
- readonly adapter: ReactRootAdapter;
251
+ /** WO-34, same reasoning as {@link PixiRootMountSpec} above: the seam's
252
+ * `RootAdapter<'dom'>` is what a DOM root actually guarantees. */
253
+ export interface ReactRootMountSpec extends RootMountSpecBase {
254
+ readonly kind: 'dom';
255
+ readonly adapter: RootAdapter<'dom'>;
360
256
  }
361
257
 
362
- export type WorldMountSpec = ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec;
258
+ export type RootMountSpec = ThreeRootMountSpec | PixiRootMountSpec | ReactRootMountSpec;
363
259
 
364
260
  /**
365
- * Mount N adapter roots (threejs + pixijs + react)
366
- * on ONE `Game`, stacked in `container` per COMPOSITION-DESIGN D5 §1.
367
- * `roots[0]` participates in `Game.defaultWorld`'s existing "first threejs
261
+ * Mount N adapter roots (three + canvas + react)
262
+ * on ONE `Game`, stacked in `container` per D5 §1.
263
+ * `roots[0]` participates in `Game.defaultRoot`'s existing "first three
368
264
  * root, else first root" rule.
369
265
  */
370
- export interface WorldsRuntimeConfig {
266
+ export interface RootsRuntimeConfig {
371
267
  /** The host creates one absolutely-positioned surface per world inside
372
- * this element (D5 §1) — a canvas for threejs/pixijs, a DOM-root `<div>`
268
+ * this element (D5 §1) — a canvas for three/canvas, a DOM-root `<div>`
373
269
  * layer for react — plus one shared UI overlay above all of them. */
374
270
  container: HTMLElement;
375
- roots: WorldMountSpec[];
271
+ roots: RootMountSpec[];
376
272
  width?: number | undefined;
377
273
  height?: number | undefined;
378
274
  /**
379
- * Mirrors `HostContext.headless` (Node conformance tests — no GPU): skips
380
- * real `WebGLRenderer` construction for every threejs world in this
275
+ * Mirrors `ThreeHostContext.headless` (Node conformance tests — no GPU): skips
276
+ * real `WebGLRenderer` construction for every three world in this
381
277
  * session (a stand-in renderer is used instead, exactly as
382
- * `VgaiSceneGameAdapter.mount` already special-cases `host.headless`
278
+ * `SetupThreeRootAdapter.mount` already special-cases `host.headless`
383
279
  * internally). Pixijs roots are unaffected — Pixi already falls back to
384
280
  * a 2D canvas renderer with no GPU. Never set `true` in a real host.
385
281
  */
@@ -387,21 +283,23 @@ export interface WorldsRuntimeConfig {
387
283
  /** D15 (T-D15.1) — the root seed `ctx.random` boots from on every world
388
284
  * mounted onto this session's Game, forwarded to `createGame` BEFORE any
389
285
  * world's `mount()`/`setup()` runs (this function constructs the Game
390
- * first — see `createWorldsGameRuntime`). The manifest-aware boot path
391
- * (`mount-manifest.ts`'s `mountManifestWorlds`) is the real caller that
286
+ * first — see `createRootsGameRuntime`). The manifest-aware boot path
287
+ * (`mount-manifest.ts`'s `mountManifestRoots`) is the real caller that
392
288
  * resolves this from `manifest.determinism`/`?vgai-seed=`/its own
393
- * explicit-config leg; a caller building `WorldsRuntimeConfig` by hand
289
+ * explicit-config leg; a caller building `RootsRuntimeConfig` by hand
394
290
  * (a test, a bespoke host) may also set it directly. Omitting it falls
395
291
  * back to `createGame`'s own fixed default. */
396
292
  seed?: number | undefined;
293
+ /** Private-play or Team Test identity supplied by the host. Networking
294
+ * remains game-owned; games may use `roomKey` in their direct room join. */
295
+ playtest?: PlaytestContext | null | undefined;
397
296
  /**
398
- * G3/FT-11 (docs/FTUE-LEARN-SYSTEM-DESIGN.md §8 G3) test-only overrides
399
- * for the render-control seam this host now wires up for EVERY session
400
- * (see `createWorldsGameRuntime`): `location` is where `?vgai-render=1`
401
- * is read from (defaults to the real `window.location`; a Node test has
402
- * no `window` and must inject one to exercise the seam), `target` is
403
- * where the harness publishes (defaults to the real `window` — override
404
- * in a unit test to avoid touching the global object; mirrors
297
+ * Test-only overrides for the render-control seam this host now wires up
298
+ * for EVERY session (see `createRootsGameRuntime`): `location` is where
299
+ * `?vgai-render=1` is read from (defaults to the real `window.location`;
300
+ * a Node test has no `window` and must inject one to exercise the seam),
301
+ * `target` is where the harness publishes (defaults to the real `window`
302
+ * override in a unit test to avoid touching the global object; mirrors
405
303
  * `RenderControlHarnessOptions.target`/`.location` and the
406
304
  * `debugBridge.url` override precedent in `mount-manifest.ts`).
407
305
  */
@@ -418,12 +316,12 @@ export interface WorldsRuntimeConfig {
418
316
  *
419
317
  * This is the GENERIC host handle — it has no first-party concepts (no
420
318
  * `GameContext`, no `GameSetupFn`). First-party features (the live `GameContext`,
421
- * warm-restart hot reload) are reached by casting `mounted` to `VgaiMountedGame`
319
+ * warm-restart hot reload) are reached by casting `mounted` to `MountedSetupThreeRoot`
422
320
  * (the editor does this for HMR/physics-sync — those are inherently first-party).
423
321
  *
424
- * `scene`/`camera`/`mounted` alias the Game's `defaultWorld`: it is the first
425
- * threejs root (else the first
426
- * world) by DECLARATION order, per `Game.defaultWorld`'s existing rule —
322
+ * `scene`/`camera`/`mounted` alias the Game's `defaultRoot`: it is the first
323
+ * three root (else the first
324
+ * world) by DECLARATION order, per `Game.defaultRoot`'s existing rule —
427
325
  * independent of `zOrder`/canvas stacking, which is a rendering-only concern.
428
326
  */
429
327
  export interface GameSession {
@@ -432,13 +330,20 @@ export interface GameSession {
432
330
  resume(): void;
433
331
  step(): void;
434
332
  /** Resize every world's render buffer. `pixelRatio` (W2c device preview)
435
- * optionally re-pins the threejs renderers' DPR in the same pass; omitted,
333
+ * optionally re-pins the three renderers' DPR in the same pass; omitted,
436
334
  * the mount-time ratio is left untouched (legacy behavior). */
437
335
  resize(width: number, height: number, pixelRatio?: number): void;
438
336
  readonly scene: THREE.Scene;
439
337
  readonly camera: THREE.PerspectiveCamera;
440
- /** The mounted game (interface surface). Cast to `VgaiMountedGame` for first-party extras. */
441
- readonly mounted: MountedGame;
338
+ /** The default world's mounted root, as the honest `MountedRoot` union
339
+ * narrow on `.kind` before reaching for a surface's own members (the same
340
+ * discriminant `scene`/`camera` above use). This deliberately does NOT
341
+ * claim `MountedThreeRoot`: a roots-path session whose default world is
342
+ * canvas or dom returns that world's mount, and the old
343
+ * `as MountedThreeRoot` cast made every such value a type-level lie. For a
344
+ * first-party three mount, narrow to `'three'` and cast to
345
+ * `MountedSetupThreeRoot` for the first-party extras. */
346
+ readonly mounted: MountedRoot;
442
347
  /** The Game root (T6.1 slice 1) — the multi-world entry point
443
348
  * (`game.roots`/`game.world(id)`/`game.queryByComponent`) for callers
444
349
  * that need more than the default-world aliases above. */
@@ -452,17 +357,17 @@ export interface GameSession {
452
357
  * renderers, loop, and asset cache. Every game — including a one-root game —
453
358
  * uses the same explicit adapter-root path.
454
359
  */
455
- export async function createGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
456
- return createWorldsGameRuntime(config);
360
+ export async function createGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
361
+ return createRootsGameRuntime(config);
457
362
  }
458
363
 
459
364
  // ---------------------------------------------------------------------------
460
- // Worlds path (T6.1 slice 1 — docs/MULTI-WORLD-DESIGN.md §1.A/B/C)
365
+ // Worlds path (T6.1 slice 1)
461
366
  // ---------------------------------------------------------------------------
462
367
 
463
- /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) threejs
464
- * roots — mirrors `VgaiSceneGameAdapter.mount`'s own `headlessComposer`
465
- * pattern (`adapter/vgai-scene-game-adapter.ts`): a headless first-party
368
+ /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) three
369
+ * roots — mirrors `SetupThreeRootAdapter.mount`'s own `headlessComposer`
370
+ * pattern (`adapter/setup-three-root-adapter.ts`): a headless first-party
466
371
  * mount never calls a render-phase method on `host.renderer` at all, so
467
372
  * this only needs to satisfy the handful of calls THIS file itself makes
468
373
  * (`setPixelRatio`/`setClearColor`/`setSize` at mount, `dispose`/
@@ -478,75 +383,30 @@ function createHeadlessRendererStub(): THREE.WebGLRenderer {
478
383
  } as unknown as THREE.WebGLRenderer;
479
384
  }
480
385
 
481
- /**
482
- * Derive a REAL default `hitTest` for a pixijs world from its own mounted
483
- * stage (D5 §2a/COMPOSITION-DESIGN.md proof item 2: "pixi `EventBoundary
484
- * .hitTest`") — used only when the caller's `WorldMountSpec` didn't declare
485
- * an explicit `hitTest`. `(x, y)` are container-relative pixels, matching
486
- * the coordinates the pixi surface renders at (the stage itself carries no
487
- * pan/zoom — that lives on `pixi-surface.ts`'s child `world` container, per
488
- * its own doc comment), so this is a correct global hit test with no extra
489
- * coordinate mapping. Duck-typed against `mounted.ctx.app` (never a
490
- * value-import of `pixi.js`) so this file stays type-only w.r.t. Pixi;
491
- * returns `undefined` (no default claim) if the mount's `app`/event
492
- * boundary isn't shaped as expected — degrade to "does not claim" rather
493
- * than throw. A non-first-party mount (D-Y1, wave 2 — same finding
494
- * `registerPixiWorld`'s doc comment records: a `{ module }` adapter's mount
495
- * has no `.ctx` at all) has nothing to duck-type here either, so this checks
496
- * the SAME `firstParty` brand FIRST and degrades identically — never claims,
497
- * never throws.
498
- */
499
- function derivePixiHitTest(
500
- mounted: MountedGame2D,
501
- ): ((x: number, y: number) => boolean) | undefined {
502
- if ((mounted as { firstParty?: unknown }).firstParty !== true) return undefined;
503
- const app = (mounted.ctx as unknown as { app?: unknown }).app as
504
- | {
505
- renderer?: {
506
- events?: {
507
- rootBoundary?: {
508
- rootTarget?: unknown;
509
- hitTest?: (x: number, y: number) => unknown;
510
- };
511
- };
512
- };
513
- }
514
- | undefined;
515
- const rootBoundary = app?.renderer?.events?.rootBoundary;
516
- if (!rootBoundary || typeof rootBoundary.hitTest !== 'function') return undefined;
517
- // Pixi assigns `rootTarget` lazily on the first render. Pointer movement
518
- // can reach the host router during that small window (or while a world is
519
- // tearing down); EventBoundary.hitTest dereferences it unconditionally.
520
- // An unready boundary cannot claim input, so degrade to no hit instead of
521
- // surfacing Pixi's internal `undefined.eventMode` exception.
522
- return (x: number, y: number) =>
523
- rootBoundary.rootTarget != null && rootBoundary.hitTest!(x, y) != null;
524
- }
525
-
526
386
  /** One already-mounted world, tracked for disposal + the router. `element`
527
- * is the world's stacked surface — an `HTMLCanvasElement` for threejs/
528
- * pixijs, or the DOM-root layer `<div>` for react (T6.2 slice 1) — kept
387
+ * is the world's stacked surface — an `HTMLCanvasElement` for three/
388
+ * canvas, or the DOM-root layer `<div>` for react (T6.2 slice 1) — kept
529
389
  * under one field name so `fullCleanup`'s disposal loop stays kind-generic
530
390
  * (`container.removeChild(entry.element)` needs no branch). */
531
391
  interface MountedAdapterRoot {
532
392
  readonly id: string;
533
- readonly kind: 'threejs' | 'pixijs' | 'react';
393
+ readonly kind: 'three' | 'canvas' | 'dom';
534
394
  readonly element: HTMLElement;
535
- readonly mounted: MountedWorld;
536
- /** Only threejs roots own a renderer this file constructed. */
395
+ readonly mounted: MountedRoot;
396
+ /** Only three roots own a renderer this file constructed. */
537
397
  readonly renderer: THREE.WebGLRenderer | undefined;
538
398
  }
539
399
 
540
- interface OneWorldResult {
400
+ interface OneRootResult {
541
401
  readonly mountedEntry: MountedAdapterRoot;
542
402
  readonly routerEntry: RouterAdapterRoot;
543
403
  }
544
404
 
545
- /** Shared per-world mount inputs, computed once in `createWorldsGameRuntime`'s
405
+ /** Shared per-world mount inputs, computed once in `createRootsGameRuntime`'s
546
406
  * loop (canvas stacking + one dpr) and threaded into whichever of
547
- * `mountOneThreeWorld`/`mountOnePixiWorld` this world's `kind` needs — split
407
+ * `mountOneThreeRoot`/`mountOnePixiRoot` this world's `kind` needs — split
548
408
  * out so the orchestrating loop itself stays a simple dispatch. */
549
- interface OneWorldContext {
409
+ interface OneRootContext {
550
410
  readonly game: GameInternal;
551
411
  readonly canvas: HTMLCanvasElement;
552
412
  readonly w: number;
@@ -554,19 +414,18 @@ interface OneWorldContext {
554
414
  readonly dpr: number;
555
415
  readonly isBottom: boolean;
556
416
  readonly headless: boolean;
557
- readonly loopHandle: LoopHandle;
558
417
  readonly assets: ReturnType<typeof createAssetCache>;
559
418
  }
560
419
 
561
- /** Mount one threejs `WorldMountSpec` (canvas + renderer construction, per
562
- * D5 §1/§4, then `registerThreeWorld`). Split out of
563
- * `createWorldsGameRuntime` purely to keep that function's own branching
564
- * simple — see `mountOnePixiWorld` for the pixijs sibling. */
565
- async function mountOneThreeWorld(
566
- spec: ThreeWorldMountSpec,
567
- ctx: OneWorldContext,
568
- ): Promise<OneWorldResult> {
569
- const { game, canvas, w, h, dpr, isBottom, headless, loopHandle, assets } = ctx;
420
+ /** Mount one three `RootMountSpec` (canvas + renderer construction, per
421
+ * D5 §1/§4, then `registerThreeRoot`). Split out of
422
+ * `createRootsGameRuntime` purely to keep that function's own branching
423
+ * simple — see `mountOnePixiRoot` for the canvas sibling. */
424
+ async function mountOneThreeRoot(
425
+ spec: ThreeRootMountSpec,
426
+ ctx: OneRootContext,
427
+ ): Promise<OneRootResult> {
428
+ const { game, canvas, w, h, dpr, isBottom, headless, assets } = ctx;
570
429
  const renderer = headless
571
430
  ? createHeadlessRendererStub()
572
431
  : createHostRenderer(canvas, w, h, undefined, {
@@ -582,7 +441,7 @@ async function mountOneThreeWorld(
582
441
  // loop above just set. The `renderer.setSize(w, h, false)` line right above
583
442
  // this comment does NOT undo that stamp (updateStyle:false only skips
584
443
  // TOUCHING style, it can't un-stamp a previous call) — so without this
585
- // re-assertion, every roots-path threejs canvas' on-screen size was
444
+ // re-assertion, every roots-path three canvas' on-screen size was
586
445
  // permanently pinned to whatever `w`/`h` it happened to mount at (usually
587
446
  // the manifest's `resolution`, since standalone builds mount before a real
588
447
  // container size is known — see `mount-manifest.ts`). Re-asserting here
@@ -604,49 +463,46 @@ async function mountOneThreeWorld(
604
463
  renderer.setPixelRatio(dpr);
605
464
  if (!isBottom) renderer.setClearColor(0x000000, 0); // D5 §1: alpha-clear above the bottom layer
606
465
 
607
- const host: HostContext = {
466
+ const host: ThreeHostContext = {
608
467
  three: THREE,
609
468
  surface: { canvas, width: w, height: h },
610
469
  renderer,
611
- loop: loopHandle,
612
470
  assets,
613
471
  headless,
614
472
  game,
615
- requestSystem: () => null,
616
473
  };
617
474
  const mounted = await spec.adapter.mount(host);
618
- registerThreeWorld(game, spec.adapter, mounted, { id: spec.id, pausable: spec.pausable });
475
+ registerThreeRoot(game, spec.adapter, mounted, { id: spec.id, pausable: spec.pausable });
619
476
  return {
620
- mountedEntry: { id: spec.id, kind: 'threejs', element: canvas, mounted, renderer },
477
+ mountedEntry: { id: spec.id, kind: 'three', element: canvas, mounted, renderer },
621
478
  routerEntry: { id: spec.id, zOrder: spec.zOrder ?? 0, canvas, hitTest: spec.hitTest },
622
479
  };
623
480
  }
624
481
 
625
- /** Mount one pixijs `WorldMountSpec` (via its own `World2DHost`, per D5
626
- * §1/§3/§4, then `registerPixiWorld`) — the pixi sibling of
627
- * `mountOneThreeWorld` above. Derives a real default `hitTest` from the
628
- * mounted stage when the spec didn't declare one (see `derivePixiHitTest`).
482
+ /** Mount one canvas `RootMountSpec` (via its own `CanvasHostContext`, per D5
483
+ * §1/§3/§4, then `registerPixiRoot`) — the pixi sibling of
484
+ * `mountOneThreeRoot` above.
629
485
  *
630
- * Unlike `mountOneThreeWorld`, this needs no explicit `canvas.style.width`/
486
+ * Unlike `mountOneThreeRoot`, this needs no explicit `canvas.style.width`/
631
487
  * `.height` re-assertion (E4.R1). `pixi-surface.ts` constructs its
632
488
  * `Application` with `autoDensity: true`, which makes PIXI ITSELF re-stamp
633
489
  * `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
634
490
  * width/height passed to `resize()`) on every `app.renderer.resize()` call —
635
491
  * including the one this world's `mounted.resize?.()` triggers from the
636
492
  * roots-path `resize()` below. So while a pixi world's canvas can start
637
- * pinned to the mount-time `w`/`h` (same as threejs, until PIXI's own
493
+ * pinned to the mount-time `w`/`h` (same as three, until PIXI's own
638
494
  * construction-time resize runs), it self-heals the moment ANY real
639
495
  * `session.resize(rw, rh)` fires — every shipped standalone entry
640
496
  * (`packages/editor/template/src/main.ts`, `examples/tri-world/src/main.ts`)
641
497
  * already calls `session.resize()` unconditionally right after mount, so
642
- * this never surfaces as a lasting bug the way threejs' buffer-only resize
498
+ * this never surfaces as a lasting bug the way three' buffer-only resize
643
499
  * did (never self-healing, by design — see above). */
644
- async function mountOnePixiWorld(
645
- spec: PixiWorldMountSpec,
646
- ctx: Omit<OneWorldContext, 'loopHandle' | 'assets'>,
647
- ): Promise<OneWorldResult> {
500
+ async function mountOnePixiRoot(
501
+ spec: PixiRootMountSpec,
502
+ ctx: Omit<OneRootContext, 'assets'>,
503
+ ): Promise<OneRootResult> {
648
504
  const { game, canvas, w, h, dpr, headless, isBottom } = ctx;
649
- const pixiHost: World2DHost = {
505
+ const pixiHost: CanvasHostContext = {
650
506
  canvas,
651
507
  width: w,
652
508
  height: h,
@@ -657,14 +513,14 @@ async function mountOnePixiWorld(
657
513
  preserveDrawingBuffer: true,
658
514
  };
659
515
  const mounted = await spec.adapter.mount(pixiHost);
660
- registerPixiWorld(game, spec.adapter, mounted, {
516
+ registerPixiRoot(game, spec.adapter, mounted, {
661
517
  id: spec.id,
662
518
  pausable: spec.pausable,
663
519
  });
664
520
  return {
665
521
  mountedEntry: {
666
522
  id: spec.id,
667
- kind: 'pixijs',
523
+ kind: 'canvas',
668
524
  element: canvas,
669
525
  mounted,
670
526
  renderer: undefined,
@@ -673,38 +529,38 @@ async function mountOnePixiWorld(
673
529
  id: spec.id,
674
530
  zOrder: spec.zOrder ?? 0,
675
531
  canvas,
676
- hitTest: spec.hitTest ?? derivePixiHitTest(mounted),
532
+ hitTest: spec.hitTest,
677
533
  },
678
534
  };
679
535
  }
680
536
 
681
537
  /**
682
- * Mount one react `WorldMountSpec` (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md`
683
- * §1.B/§1.C) the react sibling of `mountOneThreeWorld`/`mountOnePixiWorld`.
684
- * Unlike its canvas-backed siblings this returns NO `routerEntry`: a react
685
- * world's DOM-root layer participates in D5's z-order/box stacking (the
686
- * caller still creates and positions its `<div>` exactly like a canvas — see
687
- * `createWorldsGameRuntime`'s stack-building loop) but needs no entry in the
688
- * delegating router's hit-test loop (§1.C — "DOM layers need no entry in the
689
- * router's hit-test loop"): the layer's own `pointer-events` discipline
690
- * (this file sets `none` on the layer by default; the mounted React tree opts specific elements back
691
- * in with `pointer-events:auto`) is what lets its interactive elements claim
692
- * events NATIVELY, via the real DOM, with zero router involvement — and lets
693
- * a click over its non-interactive (transparent) area fall through to the
694
- * canvas below it via the SAME native DOM hit-testing (a `pointer-events:none`
695
- * element is invisible to hit-testing entirely, so the click lands on
696
- * whatever real DOM element is beneath it — the router's normal
697
- * canvas-vs-canvas forwarding, unaffected by this layer's presence).
538
+ * Mount one react `RootMountSpec` (T6.2 slice 1) — the react sibling of
539
+ * `mountOneThreeRoot`/`mountOnePixiRoot`. Unlike its canvas-backed siblings
540
+ * this returns NO `routerEntry`: a react world's DOM-root layer participates in
541
+ * D5's z-order/box stacking (the caller still creates and positions its `<div>`
542
+ * exactly like a canvas — see `createRootsGameRuntime`'s stack-building loop)
543
+ * but needs no entry in the delegating router's hit-test loop (§1.C — "DOM
544
+ * layers need no entry in the router's hit-test loop"): the layer's own
545
+ * `pointer-events` discipline (this file sets `none` on the layer by default;
546
+ * the mounted React tree opts specific elements back in with
547
+ * `pointer-events:auto`) is what lets its interactive elements claim events
548
+ * NATIVELY, via the real DOM, with zero router involvement — and lets a click
549
+ * over its non-interactive (transparent) area fall through to the canvas below
550
+ * it via the SAME native DOM hit-testing (a `pointer-events:none` element is
551
+ * invisible to hit-testing entirely, so the click lands on whatever real DOM
552
+ * element is beneath it — the router's normal canvas-vs-canvas forwarding,
553
+ * unaffected by this layer's presence).
698
554
  */
699
- async function mountOneReactWorld(
700
- spec: ReactWorldMountSpec,
555
+ async function mountOneReactRoot(
556
+ spec: ReactRootMountSpec,
701
557
  game: GameInternal,
702
558
  /** The ALREADY-created, already-stacked (position/z-index set, appended to
703
559
  * `container`) DOM-root layer for this world — see
704
- * `createWorldsGameRuntime`'s surface-stack loop, which builds a `<div>`
560
+ * `createRootsGameRuntime`'s surface-stack loop, which builds a `<div>`
705
561
  * for every react-kind spec up front, in the SAME pass that builds every
706
562
  * other world's canvas. This function must reuse that exact element (never
707
- * create its own) so `WorldInstance.reactRoot()` returns the SAME node
563
+ * create its own) so `RootInstance.reactRoot()` returns the SAME node
708
564
  * that is actually positioned in the stack. */
709
565
  layer: HTMLElement,
710
566
  ): Promise<{ mountedEntry: MountedAdapterRoot }> {
@@ -715,16 +571,16 @@ async function mountOneReactWorld(
715
571
  // block (found by the T6.2 slice-3 e2e — clicks landed outside the game).
716
572
  layer.style.width = '100%';
717
573
  layer.style.height = '100%';
718
- const reactHost: ReactWorldHost = { container: layer, game };
574
+ const reactHost: DomHostContext = { container: layer, game };
719
575
  const mounted = await spec.adapter.mount(reactHost);
720
- registerReactWorld(game, spec.adapter, mounted, layer, {
576
+ registerReactRoot(game, spec.adapter, mounted, layer, {
721
577
  id: spec.id,
722
578
  pausable: spec.pausable,
723
579
  });
724
580
  return {
725
581
  mountedEntry: {
726
582
  id: spec.id,
727
- kind: 'react',
583
+ kind: 'dom',
728
584
  element: layer,
729
585
  mounted,
730
586
  renderer: undefined,
@@ -735,24 +591,24 @@ async function mountOneReactWorld(
735
591
  /**
736
592
  * The roots-path implementer behind {@link createGameRuntime} (T6.1 slice
737
593
  * 1; react roots added T6.2 slice 1). Builds ONE surface per world — a
738
- * canvas for threejs/pixijs, a DOM-root `<div>` layer for react — stacked
739
- * per COMPOSITION-DESIGN D5 §1, z-order/ties exactly matching
594
+ * canvas for three/canvas, a DOM-root `<div>` layer for react — stacked
595
+ * per D5 §1, z-order/ties exactly matching
740
596
  * `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
741
- * via `registerThreeWorld`/`registerPixiWorld`/`registerReactWorld` — the
597
+ * via `registerThreeRoot`/`registerPixiRoot`/`registerReactRoot` — the
742
598
  * SAME wiring `test/game-three-plus-pixi.test.ts` proves by hand for the
743
- * three/pixi pair. Worlds MOUNT in `roots` ARRAY order (GAME-ROOT-DESIGN §4's
744
- * "manifest declaration order" — the frame/registration axis), independent of
599
+ * three/pixi pair. Worlds MOUNT in `roots` ARRAY order ("manifest
600
+ * declaration order" — the frame/registration axis), independent of
745
601
  * `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
746
602
  * and both are honored correctly.
747
603
  */
748
604
 
749
605
  /**
750
606
  * Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the roots
751
- * path's DEFAULT world (E4) — split out of `createWorldsGameRuntime` purely
607
+ * path's DEFAULT world (E4) — split out of `createRootsGameRuntime` purely
752
608
  * to keep that function's own cyclomatic complexity down. Mirrors the
753
609
  * legacy single-world path's identical exposure (above, in this same file),
754
- * using the SAME "first threejs world, else none" default-world rule the
755
- * `GameSession.scene`/`.camera` getters alias (`Game.defaultWorld`): a
610
+ * using the SAME "first three world, else none" default-world rule the
611
+ * `GameSession.scene`/`.camera` getters alias (`Game.defaultRoot`): a
756
612
  * non-threejs default world (or none at all) publishes neither global,
757
613
  * exactly like those getters return `undefined` in that case. Returns a
758
614
  * retraction callback — identity-guarded (a newer session's globals must
@@ -760,74 +616,87 @@ async function mountOneReactWorld(
760
616
  * path's own cleanup guards it) — that is a no-op when nothing was
761
617
  * published (non-DEV build, or non-threejs default world).
762
618
  */
763
- function installDefaultWorldDevGlobals(game: GameInternal): () => void {
619
+ function installDefaultRootDevGlobals(game: GameInternal): () => void {
764
620
  if (!import.meta.env?.DEV) return () => {};
765
- const defaultMounted = game.defaultWorld.mounted;
766
- if (defaultMounted.kind !== 'threejs') return () => {};
621
+ const defaultMounted = game.defaultRoot.mounted;
622
+ if (defaultMounted.kind !== 'three') return () => {};
767
623
  const scene = defaultMounted.scene;
768
- const camera = defaultMounted.camera as THREE.PerspectiveCamera;
769
624
  const w = window as unknown as Record<string, unknown>;
770
625
  w['__vgaiScene'] = scene;
771
- w['__vgaiCamera'] = camera;
626
+ // An ACCESSOR, not a value read once: a world may replace its camera after
627
+ // mount (fiber's `set({ camera })` — drei's `makeDefault`, or a translated
628
+ // Godot world installing the camera its `.tscn` authors), and a snapshot
629
+ // here reads as authoritative to the probe that reaches for it while naming
630
+ // a camera the frame no longer uses. The retraction guard compares the
631
+ // GETTER's identity, which is the same "don't clobber a newer session"
632
+ // rule the value comparison was.
633
+ const readCamera = (): THREE.PerspectiveCamera =>
634
+ defaultMounted.camera as THREE.PerspectiveCamera;
635
+ Object.defineProperty(w, '__vgaiCamera', {
636
+ get: readCamera,
637
+ configurable: true,
638
+ enumerable: true,
639
+ });
772
640
  return () => {
773
641
  if (w['__vgaiScene'] === scene) delete w['__vgaiScene'];
774
- if (w['__vgaiCamera'] === camera) delete w['__vgaiCamera'];
642
+ if (Object.getOwnPropertyDescriptor(w, '__vgaiCamera')?.get === readCamera)
643
+ delete w['__vgaiCamera'];
775
644
  };
776
645
  }
777
646
 
778
- /** Shared inputs `mountAllWorldSpecs` needs beyond each individual spec —
779
- * everything `OneWorldContext` needs except the per-world `canvas`/
647
+ /** Shared inputs `mountAllRootSpecs` needs beyond each individual spec —
648
+ * everything `OneRootContext` needs except the per-world `canvas`/
780
649
  * `isBottom`, plus the surface lookup and bottom-id needed to derive them. */
781
- interface MountAllWorldsInputs extends Omit<OneWorldContext, 'canvas' | 'isBottom'> {
650
+ interface MountAllRootsInputs extends Omit<OneRootContext, 'canvas' | 'isBottom'> {
782
651
  readonly surfacesById: Map<string, HTMLElement>;
783
652
  }
784
653
 
785
- interface MountAllWorldsResult {
654
+ interface MountAllRootsResult {
786
655
  readonly mountedEntries: MountedAdapterRoot[];
787
656
  readonly routerEntries: RouterAdapterRoot[];
788
657
  }
789
658
 
790
659
  /**
791
660
  * Mount + register every world spec, in ARRAY (declaration) order — split
792
- * out of `createWorldsGameRuntime` purely to keep that function's own
793
- * cyclomatic complexity down (E4, same reason `mountOneThreeWorld`/
794
- * `mountOnePixiWorld` are already split out). React roots contribute NO
795
- * router entry (docs/REACT-WORLD-DESIGN.md §1.C — "DOM layers need no entry
796
- * in the router's hit-test loop"): their layer's own `pointer-events`
797
- * discipline handles claim/fall-through natively, with zero router
798
- * involvement (see `mountOneReactWorld`'s doc comment).
661
+ * out of `createRootsGameRuntime` purely to keep that function's own
662
+ * cyclomatic complexity down (E4, same reason `mountOneThreeRoot`/
663
+ * `mountOnePixiRoot` are already split out). React roots contribute NO
664
+ * router entry ("DOM layers need no entry in the router's hit-test loop"):
665
+ * their layer's own `pointer-events` discipline handles claim/fall-through
666
+ * natively, with zero router involvement (see `mountOneReactRoot`'s doc
667
+ * comment).
799
668
  */
800
- async function mountAllWorldSpecs(
801
- mountSpecs: (ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec)[],
669
+ async function mountAllRootSpecs(
670
+ mountSpecs: (ThreeRootMountSpec | PixiRootMountSpec | ReactRootMountSpec)[],
802
671
  bottomId: string | undefined,
803
- inputs: MountAllWorldsInputs,
804
- ): Promise<MountAllWorldsResult> {
672
+ inputs: MountAllRootsInputs,
673
+ ): Promise<MountAllRootsResult> {
805
674
  const { surfacesById, ...shared } = inputs;
806
675
  const mountedEntries: MountedAdapterRoot[] = [];
807
676
  const routerEntries: RouterAdapterRoot[] = [];
808
677
 
809
678
  for (const spec of mountSpecs) {
810
679
  const isBottom = spec.id === bottomId;
811
- if (spec.kind === 'react') {
680
+ if (spec.kind === 'dom') {
812
681
  const layer = surfacesById.get(spec.id)!;
813
- const { mountedEntry } = await mountOneReactWorld(spec, shared.game, layer);
682
+ const { mountedEntry } = await mountOneReactRoot(spec, shared.game, layer);
814
683
  mountedEntries.push(mountedEntry);
815
684
  continue;
816
685
  }
817
686
  const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
818
- const oneCtx: OneWorldContext = { ...shared, canvas, isBottom };
687
+ const oneCtx: OneRootContext = { ...shared, canvas, isBottom };
819
688
  let mountedEntry: MountedAdapterRoot;
820
689
  let routerEntry: RouterAdapterRoot;
821
- if (spec.kind === 'threejs') {
822
- ({ mountedEntry, routerEntry } = await mountOneThreeWorld(spec, oneCtx));
823
- } else if (spec.kind === 'pixijs') {
824
- ({ mountedEntry, routerEntry } = await mountOnePixiWorld(spec, oneCtx));
690
+ if (spec.kind === 'three') {
691
+ ({ mountedEntry, routerEntry } = await mountOneThreeRoot(spec, oneCtx));
692
+ } else if (spec.kind === 'canvas') {
693
+ ({ mountedEntry, routerEntry } = await mountOnePixiRoot(spec, oneCtx));
825
694
  } else {
826
695
  // Exhaustiveness guard (§7.4-2): 'react' was already handled by the
827
696
  // early `continue` above, so only a hypothetical 4th `AdapterSurface` can
828
697
  // reach here — fail loudly rather than silently defaulting. `spec`
829
698
  // itself (not `spec.kind`) is what TS has narrowed to `never`, since
830
- // `WorldMountSpec` is a discriminated union at the object level.
699
+ // `RootMountSpec` is a discriminated union at the object level.
831
700
  assertNever(spec, 'create-runtime mount loop');
832
701
  }
833
702
  mountedEntries.push(mountedEntry);
@@ -842,9 +711,9 @@ async function mountAllWorldSpecs(
842
711
  * `stop()`, same leave-nothing-live rule as the debug bridge's `uninstall()`
843
712
  * in mount-manifest.ts — render-control has no uninstall surface of its own,
844
713
  * a render page being a single-load host by design, so the retraction is
845
- * the publication's inverse). Split out of `createWorldsGameRuntime` purely
714
+ * the publication's inverse). Split out of `createRootsGameRuntime` purely
846
715
  * to keep that function's cyclomatic complexity down (the same reason
847
- * `mountAllWorldSpecs`/`installDefaultWorldDevGlobals` are split out).
716
+ * `mountAllRootSpecs`/`installDefaultRootDevGlobals` are split out).
848
717
  *
849
718
  * The clock is a documented no-op: a manifest-mounted game has no global
850
719
  * cinematic AnimationClock to seek (a cinematic fixture that HAS one
@@ -874,7 +743,7 @@ function installSessionRenderHarness(
874
743
  },
875
744
  readiness: {
876
745
  // Mount is complete by the time this harness exists (the install site
877
- // in `createWorldsGameRuntime` is after `mountAllWorldSpecs`
746
+ // in `createRootsGameRuntime` is after `mountAllRootSpecs`
878
747
  // resolved) — the hook resolving immediately IS the readiness
879
748
  // statement, not an assumption.
880
749
  scene() {},
@@ -887,12 +756,12 @@ function installSessionRenderHarness(
887
756
  };
888
757
  }
889
758
 
890
- async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
891
- const { container, roots: specs, width, height, headless = false, seed } = config;
759
+ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
760
+ const { container, roots: specs, width, height, headless = false, seed, playtest } = config;
892
761
  if (specs.length === 0) {
893
762
  throw new Error('createGameRuntime: `roots` must contain at least one root.');
894
763
  }
895
- const mountSpecs = specs as (ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec)[];
764
+ const mountSpecs = specs as (ThreeRootMountSpec | PixiRootMountSpec | ReactRootMountSpec)[];
896
765
 
897
766
  const w = Math.max(
898
767
  1,
@@ -908,24 +777,42 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
908
777
 
909
778
  if (!container.style.position) container.style.position = 'relative';
910
779
 
911
- // --- Surface stack (D5 §1): DOM/z-order follows zOrder, ties -> array
912
- // order — computed FIRST (bottom -> top) so both the z-index assignment
913
- // below and the router's default-claim rule share one definition. A react
914
- // world's DOM-root layer shares this SAME stacking pass (T6.2 slice 1,
915
- // docs/REACT-WORLD-DESIGN.md §1.B — "one stacking model, no special case")
916
- // even though it is a `<div>`, not a canvas, and carries no `hitTest` (the
917
- // router never sees react entries at all — see the dispatch loop below). ---
918
- const stacked = stackOrder(
919
- mountSpecs.map((spec) => ({ id: spec.id, zOrder: spec.zOrder ?? 0, hitTest: spec.hitTest })),
920
- );
780
+ // --- Surface stack (D5 §1): DOM/z-order follows zOrder, ties -> array order
781
+ // — computed FIRST (bottom -> top) so both the z-index assignment below and
782
+ // the router's default-claim rule share one definition. A react world's
783
+ // DOM-root layer shares this SAME stacking pass (T6.2 slice 1 — "one
784
+ // stacking model, no special case") even though it is a `<div>`, not a
785
+ // canvas, and carries no `hitTest` (the router never sees react entries at
786
+ // all — see the dispatch loop below). ---
787
+ // Dev layers sit TOPMOST, above every non-dev root, whatever zOrder either
788
+ // declares: a dev GUI the game can paint over is not a dev GUI. Expressed as
789
+ // two independent `stackOrder` passes concatenated rather than a magic
790
+ // zOrder value, so `stackOrder`'s one rule (ascending zOrder, ties -> array
791
+ // order) still governs WITHIN each band and nothing has to reason about what
792
+ // number is "high enough".
793
+ const claimEntries = mountSpecs.map((spec) => ({
794
+ id: spec.id,
795
+ zOrder: spec.zOrder ?? 0,
796
+ hitTest: spec.hitTest,
797
+ dev: spec.dev === true,
798
+ }));
799
+ const stacked = [
800
+ ...stackOrder(claimEntries.filter((e) => !e.dev)),
801
+ ...stackOrder(claimEntries.filter((e) => e.dev)),
802
+ ];
921
803
  const bottomId = stacked[0]?.id;
922
804
  const kindById = new Map(mountSpecs.map((spec) => [spec.id, spec.kind] as const));
923
805
 
924
806
  const surfacesById = new Map<string, HTMLElement>();
925
807
  stacked.forEach((entry, i) => {
926
- const isReact = kindById.get(entry.id) === 'react';
808
+ const isReact = kindById.get(entry.id) === 'dom';
927
809
  const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
928
- surface.dataset['vgaiWorldSurface'] = 'true';
810
+ surface.dataset['vgaiRootSurface'] = 'true';
811
+ // The capture's marker (`composite-screenshot.ts` reads exactly this
812
+ // attribute): a dev layer is excluded from a play capture unless the
813
+ // capture asks for it, because a contaminated "does the game look right"
814
+ // frame is worthless.
815
+ if (entry.dev) surface.dataset['vgaiDevLayer'] = 'true';
929
816
  if (!isReact) {
930
817
  const canvas = surface as unknown as HTMLCanvasElement;
931
818
  canvas.width = w;
@@ -938,10 +825,10 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
938
825
  // concern only). Without this, a canvas with no CSS size falls back to
939
826
  // its `width`/`height` attribute as its CSS layout size too, so whatever
940
827
  // stamped those attributes (`createHostRenderer`'s construction-time
941
- // `setSize`, `mountOneThreeWorld` below) pins the ON-SCREEN size — see
828
+ // `setSize`, `mountOneThreeRoot` below) pins the ON-SCREEN size — see
942
829
  // that function's doc comment for the concrete bug this caused (template
943
830
  // standalone at any viewport ≠ the manifest resolution). Setting it HERE
944
- // too (not just in `mountOneThreeWorld`) means every surface, whatever
831
+ // too (not just in `mountOneThreeRoot`) means every surface, whatever
945
832
  // kind, starts container-relative from its very first paint, before any
946
833
  // per-kind mount work has even run.
947
834
  // `contain:layout paint` makes each surface the CONTAINING BLOCK for
@@ -958,22 +845,15 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
958
845
  });
959
846
 
960
847
  const assets = createAssetCache();
961
- const extraUpdaters = new Set<(dt: number) => void>();
962
- const loopHandle: LoopHandle = {
963
- onUpdate(fn) {
964
- extraUpdaters.add(fn);
965
- return () => extraUpdaters.delete(fn);
966
- },
967
- };
968
- // G3/FT-11 (docs/FTUE-LEARN-SYSTEM-DESIGN.md §8 G3): every manifest/host-
969
- // mounted game is a deterministic-capture candidate — the render-control
970
- // seam (`?vgai-render=1`, render-control.ts) is wired HERE, at the one
971
- // host every boot path shares (mountGameFromManifest, mountManifestWorlds,
972
- // and direct createGameRuntime callers all reach this function), instead
973
- // of asking every project's entry page to install it the way the e2e
974
- // fixtures do. Production-protected twice over: `isRenderModeRequested`
975
- // gates on the query param, and `installRenderControlHarness` re-checks it
976
- // internally (its AC 4), so a normal gameplay page sees zero change.
848
+ // Every manifest/host-mounted game is a deterministic-capture candidate —
849
+ // the render-control seam (`?vgai-render=1`, render-control.ts) is wired
850
+ // HERE, at the one host every boot path shares (mountGameFromManifest,
851
+ // mountManifestRoots, and direct createGameRuntime callers all reach this
852
+ // function), instead of asking every project's entry page to install it
853
+ // the way the e2e fixtures do. Production-protected twice over:
854
+ // `isRenderModeRequested` gates on the query param, and
855
+ // `installRenderControlHarness` re-checks it internally (its AC 4), so a
856
+ // normal gameplay page sees zero change.
977
857
  const renderModeLocation =
978
858
  config.renderControl?.location ?? (typeof window !== 'undefined' ? window.location : undefined);
979
859
  const renderMode = renderModeLocation !== undefined && isRenderModeRequested(renderModeLocation);
@@ -986,26 +866,35 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
986
866
  // host's explicit `simulateSubsteps`/`renderOnce` calls — wall-clock time
987
867
  // passing while a screenshot is taken must not move the world.
988
868
  externalDrive: renderMode,
869
+ // WO-P13 — sim and presentation are wired to different callbacks here:
870
+ // `update` runs the gameplay phases once per consumed fixed substep with
871
+ // `preRender`/`render` withheld, and `render` runs those two once per real
872
+ // display frame with the interpolation alpha. Both closures are invoked
873
+ // only from the loop's own rAF arm, which `externalDrive` never arms — so
874
+ // a capture/offline-export page (`renderMode`) reaches neither, and its
875
+ // `simulateSubsteps` → `game.runFrame(fixedDt)` path stays frame-exact,
876
+ // rendering inside the substep exactly as it did before this flip.
989
877
  update: (dt) => {
990
- if (started) game.runFrame(dt);
991
- for (const fn of extraUpdaters) fn(dt);
878
+ if (started) game.runFrame(dt, { skipRenderPhases: true });
879
+ },
880
+ render: (alpha, displayDt) => {
881
+ if (started) game.runRenderFrame(alpha, displayDt);
992
882
  },
993
883
  });
994
- const game = createGame({ loop, assets, seed });
884
+ const game = createGame({ loop, assets, seed, playtest });
995
885
 
996
886
  // --- Mount + register every world, in ARRAY (declaration) order. ---
997
887
  // Split out into its own top-level function purely to keep
998
- // `createWorldsGameRuntime`'s own cyclomatic complexity down (E4) — same
999
- // reason `mountOneThreeWorld`/`mountOnePixiWorld` are already split out
888
+ // `createRootsGameRuntime`'s own cyclomatic complexity down (E4) — same
889
+ // reason `mountOneThreeRoot`/`mountOnePixiRoot` are already split out
1000
890
  // below.
1001
- const { mountedEntries, routerEntries } = await mountAllWorldSpecs(mountSpecs, bottomId, {
891
+ const { mountedEntries, routerEntries } = await mountAllRootSpecs(mountSpecs, bottomId, {
1002
892
  game,
1003
893
  surfacesById,
1004
894
  w,
1005
895
  h,
1006
896
  dpr,
1007
897
  headless,
1008
- loopHandle,
1009
898
  assets,
1010
899
  });
1011
900
 
@@ -1017,16 +906,16 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1017
906
 
1018
907
  // Expose scene & camera for dev tools / e2e tests — mirrors the legacy
1019
908
  // single-world path's identical dev-only exposure above, generalized to
1020
- // the roots path's DEFAULT world (E4, docs/unified-world-editor/
1021
- // 27-visual-react-editing.md §7 E4): `mountGameFromManifest`/
1022
- // `mountManifestWorlds` route every caller (including a single-threejs-
1023
- // world scaffold project) through THIS path, so a caller migrating off
1024
- // the legacy `{canvas, adapter}` call must not silently lose
1025
- // `window.__vgaiScene`/`__vgaiCamera` — real e2e/dev tooling depends on
1026
- // them (`packages/editor/e2e/tests/04-standalone-game.spec.ts`). Split
1027
- // into its own top-level helper (with its retraction counterpart below)
909
+ // the roots path's DEFAULT world (E4): `mountGameFromManifest`/
910
+ // `mountManifestRoots` route every caller (including a
911
+ // single-threejs-world scaffold project) through THIS path, so a caller
912
+ // migrating off the legacy `{canvas, adapter}` call must not silently
913
+ // lose `window.__vgaiScene`/`__vgaiCamera` real e2e/dev tooling
914
+ // depends on them
915
+ // (`packages/editor/e2e/tests/04-standalone-game.spec.ts`). Split into
916
+ // its own top-level helper (with its retraction counterpart below)
1028
917
  // purely to keep this function's own cyclomatic complexity down.
1029
- const retractDevGlobals = installDefaultWorldDevGlobals(game);
918
+ const retractDevGlobals = installDefaultRootDevGlobals(game);
1030
919
 
1031
920
  // G3/FT-11 — publish `window.__vgaiRender` for the deterministic capture
1032
921
  // host. Installed at the TAIL of the mount (same position as
@@ -1093,23 +982,23 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1093
982
  entry.mounted.resize?.(safeWidth, safeHeight);
1094
983
  }
1095
984
  },
1096
- // `GameSession.scene`/`.camera`/`.mounted` alias `Game.defaultWorld` (T6.1
1097
- // slice 1's "first threejs world, else first world" rule) — a legacy
985
+ // `GameSession.scene`/`.camera`/`.mounted` alias `Game.defaultRoot` (T6.1
986
+ // slice 1's "first three world, else first world" rule) — a legacy
1098
987
  // convenience shaped for the threejs-only past. T7.5 narrows the read via
1099
988
  // the `kind` discriminant instead of a blind `.scene`/`.camera` cast
1100
989
  // through a nonexistent property (identical behavior to before: still
1101
990
  // `undefined` for a roots-path session whose default world isn't
1102
- // threejs — real per-world surface routing for that case is T7.6's).
991
+ // three — real per-world surface routing for that case is T7.6's).
1103
992
  get scene() {
1104
- const m = game.defaultWorld.mounted;
1105
- return (m.kind === 'threejs' ? m.scene : undefined) as THREE.Scene;
993
+ const m = game.defaultRoot.mounted;
994
+ return (m.kind === 'three' ? m.scene : undefined) as THREE.Scene;
1106
995
  },
1107
996
  get camera() {
1108
- const m = game.defaultWorld.mounted;
1109
- return (m.kind === 'threejs' ? m.camera : undefined) as THREE.PerspectiveCamera;
997
+ const m = game.defaultRoot.mounted;
998
+ return (m.kind === 'three' ? m.camera : undefined) as THREE.PerspectiveCamera;
1110
999
  },
1111
1000
  get mounted() {
1112
- return game.defaultWorld.mounted as MountedGame;
1001
+ return game.defaultRoot.mounted;
1113
1002
  },
1114
1003
  game,
1115
1004
  };