@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three';
2
+ import { assertNever } from '../adapter/adapter-surface';
2
3
  import type {
3
4
  GameAdapter,
4
5
  MountedGame,
@@ -6,8 +7,6 @@ import type {
6
7
  MountedWorld,
7
8
  } from '../adapter/game-adapter';
8
9
  import type { HostContext, LoopHandle } from '../adapter/host-context';
9
- import { type VgaiSceneConfig, VgaiSceneGameAdapter } from '../adapter/vgai-scene-game-adapter';
10
- import { assertNever } from '../adapter/world-kind';
11
10
  import { createAssetCache } from '../assets';
12
11
  import { createGameLoop } from '../core/game-loop';
13
12
  import { createHostRenderer } from '../setup/setup-renderer';
@@ -25,7 +24,7 @@ import {
25
24
  isFirstPartyMounted,
26
25
  type WorldInstance,
27
26
  } from './game';
28
- import { createInputRouter, type RouterWorldEntry, stackOrder } from './input-router';
27
+ import { createInputRouter, type RouterAdapterRoot, stackOrder } from './input-router';
29
28
 
30
29
  // Re-export the camera-precedence helper from its new home so existing importers
31
30
  // (`import { adoptSceneCamera } from '@engine/runtime/create-runtime'`) keep working.
@@ -35,7 +34,7 @@ export { adoptSceneCamera } from '../adapter/vgai-scene-game-adapter';
35
34
  * Register a threejs world for a freshly-mounted game onto the Game shell.
36
35
  * Shared by `createGameRuntime` and every headless test harness
37
36
  * (`test/game-root.test.ts`, `test/frame-order.test.ts`,
38
- * `test/game-two-worlds.test.ts`) so there is exactly one code path for this
37
+ * `test/game-two-roots.test.ts`) so there is exactly one code path for this
39
38
  * wiring (T7.1 slice 1, id-generalized in slice 3 for the two-world proof —
40
39
  * `docs/GAME-ROOT-DESIGN.md` §8 stage 3) — `physics`/`collisions`/`camera`/
41
40
  * `frame` are populated from the mount's first-party `GameContext`/
@@ -94,25 +93,14 @@ export function registerThreeWorld(
94
93
  return world;
95
94
  }
96
95
 
97
- /** Thin alias kept for existing single-world callers (`createGameRuntime`,
98
- * `test/game-root.test.ts`, `test/frame-order.test.ts`) — registers the
99
- * default `'main'` world via the SAME `registerThreeWorld` code path. */
100
- export function registerDefaultThreeWorld(
101
- game: GameInternal,
102
- adapter: GameAdapter,
103
- mounted: MountedGame,
104
- ): WorldInstance {
105
- return registerThreeWorld(game, adapter, mounted);
106
- }
107
-
108
96
  /**
109
97
  * Register a pixijs world onto the Game shell — the pixi analog of
110
98
  * {@link registerThreeWorld}, mirroring the wiring
111
99
  * `world2d/pixi-game-adapter.ts`'s `createWorld2DRuntime` performs for its
112
100
  * OWN (separate) Game, and `test/game-three-plus-pixi.test.ts` performs by
113
- * hand onto a SHARED Game. `createGameRuntime`'s worlds path
101
+ * hand onto a SHARED Game. `createGameRuntime`'s roots path
114
102
  * (T6.1 slice 1) is the first PRODUCTION caller that registers a pixijs
115
- * world alongside other worlds on one Game; this is its one wiring code
103
+ * world alongside other roots on one Game; this is its one wiring code
116
104
  * path, so a second/third pixijs world (or a future caller) never
117
105
  * re-derives it. `adapter` is typed as the real {@link Pixi2DGameAdapter}
118
106
  * shape (T7.5 — previously the generic `GameAdapter`, which forced a cast at
@@ -132,7 +120,7 @@ export function registerDefaultThreeWorld(
132
120
  * only via `adapter as unknown as Pixi2DGameAdapter` (a custom module's actual
133
121
  * return value is merely `MountedPixiWorld`-shaped, T7.5's cross-kind union),
134
122
  * so the "never actually fire" premise no longer holds the moment a custom
135
- * pixi module world reaches the worlds path. Rather than keep the throw (which
123
+ * pixi module world reaches the roots path. Rather than keep the throw (which
136
124
  * would re-create exactly the kind of dead end D-X6 named and D-Y1 exists to
137
125
  * remove), this now degrades EXACTLY like `registerThreeWorld`'s own foreign-
138
126
  * mount handling above (`firstPartyExtras`): `physics2d`/`collisions2d`/
@@ -194,13 +182,13 @@ export function registerPixiWorld(
194
182
  * its commits; `GameInternal.runFrame` correctly leaves a world with no
195
183
  * `frame` untouched by its opaque-`update` fallback too, since
196
184
  * `MountedReactGame` declares no `update`). `adapter` is typed as the real
197
- * {@link ReactWorldAdapter} shape (T7.5, same reasoning as
185
+ * {@link ReactRootAdapter} shape (T7.5, same reasoning as
198
186
  * `registerPixiWorld`'s doc comment above) — `WorldInstance.adapter` only
199
187
  * needs `.id`, so this passes through with zero cast.
200
188
  */
201
189
  export function registerReactWorld(
202
190
  game: GameInternal,
203
- adapter: ReactWorldAdapter,
191
+ adapter: ReactRootAdapter,
204
192
  mounted: MountedReactGame,
205
193
  container: HTMLElement,
206
194
  opts?: { id?: string | undefined; pausable?: boolean | undefined },
@@ -217,28 +205,6 @@ export function registerReactWorld(
217
205
  return world;
218
206
  }
219
207
 
220
- /**
221
- * Configuration for {@link createGameRuntime}.
222
- *
223
- * The host depends on a {@link GameAdapter}. For convenience, the legacy
224
- * first-party fields (`setup`/`scenePath`/`sceneData`/…) are accepted and wrapped
225
- * in a {@link VgaiSceneGameAdapter} when no explicit `adapter` is given — so the
226
- * host's default implementer is first-party, but the host itself only ever
227
- * mounts "a GameAdapter".
228
- *
229
- * This is the LEGACY (single-world) shape — preserved byte-for-byte (T6.1
230
- * slice 1, docs/MULTI-WORLD-DESIGN.md §1.A): every existing caller and test
231
- * passes this shape unmodified. See {@link WorldsRuntimeConfig} for the new
232
- * multi-world alternative and {@link RuntimeConfig} for the union.
233
- */
234
- export interface LegacyRuntimeConfig extends VgaiSceneConfig {
235
- canvas: HTMLCanvasElement;
236
- width?: number | undefined;
237
- height?: number | undefined;
238
- /** An explicit game adapter to mount (first-party OR external). */
239
- adapter?: GameAdapter | undefined;
240
- }
241
-
242
208
  /**
243
209
  * A pixijs-shaped adapter — the structural contract `WorldMountSpec`'s
244
210
  * `pixijs` variant requires. `PixiSceneGameAdapter` (`world2d/
@@ -252,17 +218,17 @@ export interface Pixi2DGameAdapter {
252
218
  }
253
219
 
254
220
  /**
255
- * One world to mount in the {@link WorldsRuntimeConfig} worlds path (T6.1
221
+ * One world to mount in the {@link WorldsRuntimeConfig} roots path (T6.1
256
222
  * slice 1) — the host-facing mirror of `manifest/load.ts`'s
257
- * `ResolvedWorldEntry` (same `id`/`zOrder`/`pausable`/`loop` fields; the
223
+ * `ResolvedAdapterRoot` (same `id`/`zOrder`/`pausable`/`loop` fields; the
258
224
  * manifest-to-host translation itself is the editor's job, not this file's).
259
225
  *
260
226
  * `react` (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md`) mounts as a DOM-root
261
227
  * layer `<div>` in the SAME stack instead of a canvas — see
262
- * {@link ReactWorldMountSpec}/{@link ReactWorldAdapter} below.
228
+ * {@link ReactWorldMountSpec}/{@link ReactRootAdapter} below.
263
229
  */
264
230
  export interface WorldMountSpecBase {
265
- /** Manifest id — must be unique within one `worlds` array. */
231
+ /** Manifest id — must be unique within one `roots` array. */
266
232
  readonly id: string;
267
233
  /** Canvas stacking order (COMPOSITION-DESIGN D5 §1); ties broken by array
268
234
  * order, mirroring `manifest/load.ts`'s `loadGameManifest` sort. Defaults
@@ -272,7 +238,7 @@ export interface WorldMountSpecBase {
272
238
  readonly pausable?: boolean | undefined;
273
239
  /** `'gated'` (host-driven, default) or `'self-driven'` (this world drives
274
240
  * its own loop — D5's "composited, unsynchronized" tier). Carried through
275
- * for parity with `ResolvedWorldEntry`; T6.1 slice 1 does not yet
241
+ * for parity with `ResolvedAdapterRoot`; T6.1 slice 1 does not yet
276
242
  * validate it against the mounted adapter's actual `drivesOwnLoop` (that
277
243
  * cross-check, if ever needed, is T7.6's loop-gate surface). */
278
244
  readonly loop?: 'gated' | 'self-driven' | undefined;
@@ -300,7 +266,7 @@ export interface PixiWorldMountSpec extends WorldMountSpecBase {
300
266
  }
301
267
 
302
268
  /**
303
- * The host-facing surface handed to a {@link ReactWorldAdapter}'s `mount`
269
+ * The host-facing surface handed to a {@link ReactRootAdapter}'s `mount`
304
270
  * (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md` §1.B) — the react analog of
305
271
  * {@link World2DHost}. `container` is the absolutely-positioned, z-ordered
306
272
  * DOM-root layer `<div>` the host already created and stacked (same box/
@@ -334,7 +300,7 @@ export interface ReactWorldHost {
334
300
 
335
301
  /**
336
302
  * A live, mounted react world (T6.2 slice 1) — the react analog of
337
- * {@link MountedGame2D}. React worlds host no ticking components (D8 —
303
+ * {@link MountedGame2D}. React roots host no ticking components (D8 —
338
304
  * `ecs/component-manager.ts` already throws on any attach to a react-kind
339
305
  * manager) and render from game state via the T7.4 bridge instead of a
340
306
  * per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
@@ -361,7 +327,7 @@ export interface ReactWorldHost {
361
327
  * game-adapter.ts`) — `container` is the SAME `ReactWorldHost.container` the
362
328
  * adapter's `mount` was handed (identity matters, mirroring `threeRoot()`/
363
329
  * `pixiRoot()`'s "same instance the adapter mounted" contract); every
364
- * `ReactWorldAdapter` implementer echoes it back here so `mounted` alone
330
+ * `ReactRootAdapter` implementer echoes it back here so `mounted` alone
365
331
  * (with no separately-threaded `container`) satisfies the union
366
332
  * `WorldInstanceInit.mounted`/`WorldInstance.mounted` with zero cast.
367
333
  */
@@ -376,35 +342,30 @@ export interface MountedReactGame extends MountedReactWorld {
376
342
  * `default-react` resolver branch, T6.2's editor-side follow-up) can satisfy
377
343
  * this shape without this file importing react-dom or any editor code.
378
344
  */
379
- export interface ReactWorldAdapter {
345
+ export interface ReactRootAdapter {
380
346
  readonly id: string;
381
347
  mount(host: ReactWorldHost): Promise<MountedReactGame>;
382
348
  }
383
349
 
384
350
  export interface ReactWorldMountSpec extends WorldMountSpecBase {
385
351
  readonly kind: 'react';
386
- readonly adapter: ReactWorldAdapter;
352
+ readonly adapter: ReactRootAdapter;
387
353
  }
388
354
 
389
355
  export type WorldMountSpec = ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec;
390
356
 
391
357
  /**
392
- * The NEW multi-world alternative to {@link LegacyRuntimeConfig} (T6.1 slice
393
- * 1, docs/MULTI-WORLD-DESIGN.md §1.A; react worlds T6.2 slice 1,
394
- * docs/REACT-WORLD-DESIGN.md §1.B): mount N worlds (threejs + pixijs + react)
358
+ * Mount N adapter roots (threejs + pixijs + react)
395
359
  * on ONE `Game`, stacked in `container` per COMPOSITION-DESIGN D5 §1.
396
- * Internally the legacy signature maps to a one-element worlds list —
397
- * `worlds[0]` (by `Game.defaultWorld`'s existing "first threejs world, else
398
- * first world" rule) is the default world, so
399
- * `GameSession.scene`/`.camera`/`.mounted` keep aliasing exactly as they do
400
- * today.
360
+ * `roots[0]` participates in `Game.defaultWorld`'s existing "first threejs
361
+ * root, else first root" rule.
401
362
  */
402
363
  export interface WorldsRuntimeConfig {
403
364
  /** The host creates one absolutely-positioned surface per world inside
404
365
  * this element (D5 §1) — a canvas for threejs/pixijs, a DOM-root `<div>`
405
366
  * layer for react — plus one shared UI overlay above all of them. */
406
367
  container: HTMLElement;
407
- worlds: WorldMountSpec[];
368
+ roots: WorldMountSpec[];
408
369
  width?: number | undefined;
409
370
  height?: number | undefined;
410
371
  /**
@@ -412,18 +373,20 @@ export interface WorldsRuntimeConfig {
412
373
  * real `WebGLRenderer` construction for every threejs world in this
413
374
  * session (a stand-in renderer is used instead, exactly as
414
375
  * `VgaiSceneGameAdapter.mount` already special-cases `host.headless`
415
- * internally). Pixijs worlds are unaffected — Pixi already falls back to
376
+ * internally). Pixijs roots are unaffected — Pixi already falls back to
416
377
  * a 2D canvas renderer with no GPU. Never set `true` in a real host.
417
378
  */
418
379
  headless?: boolean | undefined;
419
- }
420
-
421
- /** The full config union `createGameRuntime` accepts — the legacy
422
- * single-`{canvas, adapter}` shape, or the new worlds path. */
423
- export type RuntimeConfig = LegacyRuntimeConfig | WorldsRuntimeConfig;
424
-
425
- function isWorldsConfig(config: RuntimeConfig): config is WorldsRuntimeConfig {
426
- return 'worlds' in config;
380
+ /** D15 (T-D15.1) — the root seed `ctx.random` boots from on every world
381
+ * mounted onto this session's Game, forwarded to `createGame` BEFORE any
382
+ * world's `mount()`/`setup()` runs (this function constructs the Game
383
+ * first — see `createWorldsGameRuntime`). The manifest-aware boot path
384
+ * (`mount-manifest.ts`'s `mountManifestWorlds`) is the real caller that
385
+ * resolves this from `manifest.determinism`/`?vgai-seed=`/its own
386
+ * explicit-config leg; a caller building `WorldsRuntimeConfig` by hand
387
+ * (a test, a bespoke host) may also set it directly. Omitting it falls
388
+ * back to `createGame`'s own fixed default. */
389
+ seed?: number | undefined;
427
390
  }
428
391
 
429
392
  /**
@@ -434,9 +397,8 @@ function isWorldsConfig(config: RuntimeConfig): config is WorldsRuntimeConfig {
434
397
  * warm-restart hot reload) are reached by casting `mounted` to `VgaiMountedGame`
435
398
  * (the editor does this for HMR/physics-sync — those are inherently first-party).
436
399
  *
437
- * `scene`/`camera`/`mounted` alias the Game's `defaultWorld` (T6.1 slice 1):
438
- * for the legacy single-world path this is the only world, byte-identical to
439
- * before; for the worlds path it is the first threejs world (else the first
400
+ * `scene`/`camera`/`mounted` alias the Game's `defaultWorld`: it is the first
401
+ * threejs root (else the first
440
402
  * world) by DECLARATION order, per `Game.defaultWorld`'s existing rule —
441
403
  * independent of `zOrder`/canvas stacking, which is a rendering-only concern.
442
404
  */
@@ -451,7 +413,7 @@ export interface GameSession {
451
413
  /** The mounted game (interface surface). Cast to `VgaiMountedGame` for first-party extras. */
452
414
  readonly mounted: MountedGame;
453
415
  /** The Game root (T6.1 slice 1) — the multi-world entry point
454
- * (`game.worlds`/`game.world(id)`/`game.queryByComponent`) for callers
416
+ * (`game.roots`/`game.world(id)`/`game.queryByComponent`) for callers
455
417
  * that need more than the default-world aliases above. */
456
418
  readonly game: Game;
457
419
  }
@@ -459,154 +421,12 @@ export interface GameSession {
459
421
  /**
460
422
  * Bootstrap the game host and mount a game adapter.
461
423
  *
462
- * `createGameRuntime` is the HOST: it owns the canvas/renderer(s), the loop,
463
- * the UI overlay, and the asset cache, and it mounts a {@link GameAdapter}
464
- * (or several, via the worlds path — T6.1 slice 1) against that context. It
465
- * knows nothing about `.vscn`, `GameComponent`, Rapier, or `GameSetupFn` —
466
- * those live inside the first-party {@link VgaiSceneGameAdapter}, which is
467
- * the default implementer when no explicit `adapter` is supplied on the
468
- * legacy path. Dispatches on `config`'s shape: {@link WorldsRuntimeConfig}
469
- * (`worlds` present) goes through {@link createWorldsGameRuntime};
470
- * everything else is the byte-compatible legacy single-world path.
424
+ * `createGameRuntime` is the universal HOST: it owns the root surfaces,
425
+ * renderers, loop, and asset cache. Every game — including a one-root game —
426
+ * uses the same explicit adapter-root path.
471
427
  */
472
- export async function createGameRuntime(config: RuntimeConfig): Promise<GameSession> {
473
- if (isWorldsConfig(config)) {
474
- return createWorldsGameRuntime(config);
475
- }
476
- const { canvas, width, height, adapter, ...sceneConfig } = config;
477
- const w = width ?? canvas.width;
478
- const h = height ?? canvas.height;
479
-
480
- // --- Host primitives (neutral — no first-party assumptions) ---
481
- const renderer = createHostRenderer(canvas, w, h);
482
- const assets = createAssetCache();
483
-
484
- let uiContainer: HTMLDivElement;
485
- const existingUi = document.getElementById('example-ui');
486
- if (existingUi instanceof HTMLDivElement) {
487
- uiContainer = existingUi;
488
- } else {
489
- uiContainer = document.createElement('div');
490
- uiContainer.id = 'example-ui';
491
- uiContainer.style.cssText =
492
- 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2;';
493
- (canvas.parentElement ?? document.getElementById('ui-overlay') ?? document.body).appendChild(
494
- uiContainer,
495
- );
496
- }
497
-
498
- const extraUpdaters = new Set<(dt: number) => void>();
499
- const loopHandle: LoopHandle = {
500
- onUpdate(fn) {
501
- extraUpdaters.add(fn);
502
- return () => extraUpdaters.delete(fn);
503
- },
504
- };
505
-
506
- // --- Game root (T7.1 slice 1 + slice 2) ---
507
- // The loop's update callback used to close directly over `mounted`, which
508
- // only exists after `game.mount(host)` resolves. The Game shell (and thus
509
- // its `loop`) must exist BEFORE mount so the mount can see `host.game` — so
510
- // the callback instead consults a mutable ref, set once mount resolves.
511
- // Behavior is identical: `loop.start()` still only runs after mount, same
512
- // pause/step/stop semantics below. `drivesOwnLoop` handling now lives
513
- // INSIDE `game.runFrame` (T7.1 slice 2) rather than being checked here —
514
- // for today's single first-party (or single opaque host-driven) world this
515
- // is byte-identical to the old `if (!mountedRef.drivesOwnLoop) mountedRef
516
- // .update?.(dt)` guard (see `runFrame`'s doc comment in `runtime/game.ts`).
517
- // `game` is referenced here before its `const` below only textually — this
518
- // closure isn't invoked until `loop.start()`, well after `game` exists.
519
- let mountedRef: MountedGame | null = null;
520
- const loop = createGameLoop({
521
- fixedTimestep: 1 / 60,
522
- maxSubSteps: 8,
523
- update: (dt) => {
524
- if (mountedRef) game.runFrame(dt);
525
- for (const fn of extraUpdaters) fn(dt);
526
- },
527
- });
528
- const game = createGame({ loop, assets });
529
-
530
- const host: HostContext = {
531
- three: THREE,
532
- surface: { canvas, width: w, height: h },
533
- renderer,
534
- loop: loopHandle,
535
- assets,
536
- ui: uiContainer,
537
- game,
538
- requestSystem: () => null,
539
- };
540
-
541
- // --- Mount the game adapter (first-party default, or an explicit adapter) ---
542
- const gameAdapter = adapter ?? new VgaiSceneGameAdapter(sceneConfig);
543
- const mounted = await gameAdapter.mount(host);
544
- mountedRef = mounted;
545
-
546
- // Register the default (and, in this slice, only) threejs world now that
547
- // mount has produced a MountedGame — the world's `threeRoot()` must return
548
- // the SAME scene instance the adapter mounted (identity is the contract).
549
- registerDefaultThreeWorld(game, gameAdapter, mounted);
550
-
551
- // Expose scene & camera for dev tools / e2e tests
552
- if (import.meta.env?.DEV) {
553
- const w = window as unknown as Record<string, unknown>;
554
- w['__vgaiScene'] = mounted.scene;
555
- w['__vgaiCamera'] = mounted.camera;
556
- }
557
-
558
- loop.start();
559
-
560
- function fullCleanup(): void {
561
- loop.stop();
562
- mounted.dispose();
563
- renderer.dispose();
564
- renderer.forceContextLoss();
565
- // Retract the dev/e2e globals THIS session published (identity-guarded so
566
- // a newer session's globals are never clobbered by an older session's
567
- // stop). Leaving them dangling let e2e waits pass vacuously against a
568
- // stopped game's scene/camera.
569
- if (import.meta.env?.DEV) {
570
- const w = window as unknown as Record<string, unknown>;
571
- if (w['__vgaiScene'] === mounted.scene) delete w['__vgaiScene'];
572
- if (w['__vgaiCamera'] === mounted.camera) delete w['__vgaiCamera'];
573
- }
574
- }
575
-
576
- return {
577
- stop: fullCleanup,
578
- // D10/T7.6: `Game.play` (`runtime/game.ts`) is the real control surface —
579
- // it fans out over every `pausable` world (just the one default world on
580
- // this legacy single-world path) via its loop-gate/audio-gate capability,
581
- // reporting honestly wherever a world can't actually be gated, and keeps
582
- // `render` running every substep while paused (the old `loop.timeScale =
583
- // 0` here starved the accumulator entirely — per D1, zero substeps means
584
- // zero renders too — which is the bug this replaces, not a behavior to
585
- // preserve).
586
- pause() {
587
- game.play.pause();
588
- },
589
- resume() {
590
- game.play.resume();
591
- },
592
- step() {
593
- game.play.step();
594
- },
595
- resize(rw: number, rh: number) {
596
- renderer.setSize(rw, rh);
597
- mounted.resize?.(rw, rh);
598
- },
599
- get scene() {
600
- return mounted.scene as THREE.Scene;
601
- },
602
- get camera() {
603
- return mounted.camera as THREE.PerspectiveCamera;
604
- },
605
- get mounted() {
606
- return mounted;
607
- },
608
- game,
609
- };
428
+ export async function createGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
429
+ return createWorldsGameRuntime(config);
610
430
  }
611
431
 
612
432
  // ---------------------------------------------------------------------------
@@ -614,7 +434,7 @@ export async function createGameRuntime(config: RuntimeConfig): Promise<GameSess
614
434
  // ---------------------------------------------------------------------------
615
435
 
616
436
  /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) threejs
617
- * worlds — mirrors `VgaiSceneGameAdapter.mount`'s own `headlessComposer`
437
+ * roots — mirrors `VgaiSceneGameAdapter.mount`'s own `headlessComposer`
618
438
  * pattern (`adapter/vgai-scene-game-adapter.ts`): a headless first-party
619
439
  * mount never calls a render-phase method on `host.renderer` at all, so
620
440
  * this only needs to satisfy the handful of calls THIS file itself makes
@@ -654,11 +474,26 @@ function derivePixiHitTest(
654
474
  ): ((x: number, y: number) => boolean) | undefined {
655
475
  if ((mounted as { firstParty?: unknown }).firstParty !== true) return undefined;
656
476
  const app = (mounted.ctx as unknown as { app?: unknown }).app as
657
- | { renderer?: { events?: { rootBoundary?: { hitTest?: (x: number, y: number) => unknown } } } }
477
+ | {
478
+ renderer?: {
479
+ events?: {
480
+ rootBoundary?: {
481
+ rootTarget?: unknown;
482
+ hitTest?: (x: number, y: number) => unknown;
483
+ };
484
+ };
485
+ };
486
+ }
658
487
  | undefined;
659
488
  const rootBoundary = app?.renderer?.events?.rootBoundary;
660
489
  if (!rootBoundary || typeof rootBoundary.hitTest !== 'function') return undefined;
661
- return (x: number, y: number) => rootBoundary.hitTest!(x, y) != null;
490
+ // Pixi assigns `rootTarget` lazily on the first render. Pointer movement
491
+ // can reach the host router during that small window (or while a world is
492
+ // tearing down); EventBoundary.hitTest dereferences it unconditionally.
493
+ // An unready boundary cannot claim input, so degrade to no hit instead of
494
+ // surfacing Pixi's internal `undefined.eventMode` exception.
495
+ return (x: number, y: number) =>
496
+ rootBoundary.rootTarget != null && rootBoundary.hitTest!(x, y) != null;
662
497
  }
663
498
 
664
499
  /** One already-mounted world, tracked for disposal + the router. `element`
@@ -666,18 +501,18 @@ function derivePixiHitTest(
666
501
  * pixijs, or the DOM-root layer `<div>` for react (T6.2 slice 1) — kept
667
502
  * under one field name so `fullCleanup`'s disposal loop stays kind-generic
668
503
  * (`container.removeChild(entry.element)` needs no branch). */
669
- interface MountedWorldEntry {
504
+ interface MountedAdapterRoot {
670
505
  readonly id: string;
671
506
  readonly kind: 'threejs' | 'pixijs' | 'react';
672
507
  readonly element: HTMLElement;
673
508
  readonly mounted: MountedWorld;
674
- /** Only threejs worlds own a renderer this file constructed. */
509
+ /** Only threejs roots own a renderer this file constructed. */
675
510
  readonly renderer: THREE.WebGLRenderer | undefined;
676
511
  }
677
512
 
678
513
  interface OneWorldResult {
679
- readonly mountedEntry: MountedWorldEntry;
680
- readonly routerEntry: RouterWorldEntry;
514
+ readonly mountedEntry: MountedAdapterRoot;
515
+ readonly routerEntry: RouterAdapterRoot;
681
516
  }
682
517
 
683
518
  /** Shared per-world mount inputs, computed once in `createWorldsGameRuntime`'s
@@ -692,7 +527,6 @@ interface OneWorldContext {
692
527
  readonly dpr: number;
693
528
  readonly isBottom: boolean;
694
529
  readonly headless: boolean;
695
- readonly uiContainer: HTMLDivElement;
696
530
  readonly loopHandle: LoopHandle;
697
531
  readonly assets: ReturnType<typeof createAssetCache>;
698
532
  }
@@ -705,7 +539,7 @@ async function mountOneThreeWorld(
705
539
  spec: ThreeWorldMountSpec,
706
540
  ctx: OneWorldContext,
707
541
  ): Promise<OneWorldResult> {
708
- const { game, canvas, w, h, dpr, isBottom, headless, uiContainer, loopHandle, assets } = ctx;
542
+ const { game, canvas, w, h, dpr, isBottom, headless, loopHandle, assets } = ctx;
709
543
  const renderer = headless
710
544
  ? createHeadlessRendererStub()
711
545
  : createHostRenderer(canvas, w, h, undefined, {
@@ -721,7 +555,7 @@ async function mountOneThreeWorld(
721
555
  // loop above just set. The `renderer.setSize(w, h, false)` line right above
722
556
  // this comment does NOT undo that stamp (updateStyle:false only skips
723
557
  // TOUCHING style, it can't un-stamp a previous call) — so without this
724
- // re-assertion, every worlds-path threejs canvas' on-screen size was
558
+ // re-assertion, every roots-path threejs canvas' on-screen size was
725
559
  // permanently pinned to whatever `w`/`h` it happened to mount at (usually
726
560
  // the manifest's `resolution`, since standalone builds mount before a real
727
561
  // container size is known — see `mount-manifest.ts`). Re-asserting here
@@ -730,14 +564,14 @@ async function mountOneThreeWorld(
730
564
  // fixes the template-standalone bug (broken at any viewport other than the
731
565
  // manifest's `resolution`, which is also Playwright's DEFAULT viewport —
732
566
  // why it went unnoticed) and, for free, tri-world's pre-existing
733
- // resize-staleness (the worlds-path `resize()` below always calls
567
+ // resize-staleness (the roots-path `resize()` below always calls
734
568
  // `setSize(rw, rh, false)`, so nothing else was ever going to update this
735
569
  // canvas' CSS after mount). `createHostRenderer`'s own default
736
570
  // (`updateStyle:true`) is intentionally left alone — the LEGACY
737
571
  // single-canvas path (editor play-mode's non-multi-world branch) still
738
572
  // relies on that construction-time stamp + its own later `updateStyle:true`
739
573
  // resizes for byte-identical behavior; this fix touches only the
740
- // worlds-path canvas, after the fact.
574
+ // roots-path canvas, after the fact.
741
575
  canvas.style.width = '100%';
742
576
  canvas.style.height = '100%';
743
577
  renderer.setPixelRatio(dpr);
@@ -749,7 +583,6 @@ async function mountOneThreeWorld(
749
583
  renderer,
750
584
  loop: loopHandle,
751
585
  assets,
752
- ui: uiContainer,
753
586
  headless,
754
587
  game,
755
588
  requestSystem: () => null,
@@ -773,7 +606,7 @@ async function mountOneThreeWorld(
773
606
  * `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
774
607
  * width/height passed to `resize()`) on every `app.renderer.resize()` call —
775
608
  * including the one this world's `mounted.resize?.()` triggers from the
776
- * worlds-path `resize()` below. So while a pixi world's canvas can start
609
+ * roots-path `resize()` below. So while a pixi world's canvas can start
777
610
  * pinned to the mount-time `w`/`h` (same as threejs, until PIXI's own
778
611
  * construction-time resize runs), it self-heals the moment ANY real
779
612
  * `session.resize(rw, rh)` fires — every shipped standalone entry
@@ -785,12 +618,12 @@ async function mountOnePixiWorld(
785
618
  spec: PixiWorldMountSpec,
786
619
  ctx: Omit<OneWorldContext, 'headless' | 'loopHandle' | 'assets'>,
787
620
  ): Promise<OneWorldResult> {
788
- const { game, canvas, w, h, dpr, isBottom, uiContainer } = ctx;
621
+ const { game, canvas, w, h, dpr, isBottom } = ctx;
789
622
  const pixiHost: World2DHost = {
790
623
  canvas,
791
624
  width: w,
792
625
  height: h,
793
- ui: uiContainer,
626
+ game,
794
627
  dpr,
795
628
  transparent: !isBottom,
796
629
  preserveDrawingBuffer: true,
@@ -826,8 +659,7 @@ async function mountOnePixiWorld(
826
659
  * `createWorldsGameRuntime`'s stack-building loop) but needs no entry in the
827
660
  * delegating router's hit-test loop (§1.C — "DOM layers need no entry in the
828
661
  * router's hit-test loop"): the layer's own `pointer-events` discipline
829
- * (this file sets `none` on the layer by default, mirroring the existing
830
- * `uiContainer`/HUD rule; the mounted react tree opts specific elements back
662
+ * (this file sets `none` on the layer by default; the mounted React tree opts specific elements back
831
663
  * in with `pointer-events:auto`) is what lets its interactive elements claim
832
664
  * events NATIVELY, via the real DOM, with zero router involvement — and lets
833
665
  * a click over its non-interactive (transparent) area fall through to the
@@ -847,10 +679,10 @@ async function mountOneReactWorld(
847
679
  * create its own) so `WorldInstance.reactRoot()` returns the SAME node
848
680
  * that is actually positioned in the stack. */
849
681
  layer: HTMLElement,
850
- ): Promise<{ mountedEntry: MountedWorldEntry }> {
682
+ ): Promise<{ mountedEntry: MountedAdapterRoot }> {
851
683
  layer.style.pointerEvents = 'none';
852
- // Size the layer like the shared uiContainer does: an absolutely-positioned
853
- // layer with no width/height collapses to zero content size, so a child's
684
+ // An absolutely-positioned layer with no width/height collapses to zero
685
+ // content size, so a child's
854
686
  // own position:absolute offsets resolve against a degenerate containing
855
687
  // block (found by the T6.2 slice-3 e2e — clicks landed outside the game).
856
688
  layer.style.width = '100%';
@@ -873,21 +705,21 @@ async function mountOneReactWorld(
873
705
  }
874
706
 
875
707
  /**
876
- * The worlds-path implementer behind {@link createGameRuntime} (T6.1 slice
877
- * 1; react worlds added T6.2 slice 1). Builds ONE surface per world — a
708
+ * The roots-path implementer behind {@link createGameRuntime} (T6.1 slice
709
+ * 1; react roots added T6.2 slice 1). Builds ONE surface per world — a
878
710
  * canvas for threejs/pixijs, a DOM-root `<div>` layer for react — stacked
879
711
  * per COMPOSITION-DESIGN D5 §1, z-order/ties exactly matching
880
712
  * `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
881
713
  * via `registerThreeWorld`/`registerPixiWorld`/`registerReactWorld` — the
882
714
  * SAME wiring `test/game-three-plus-pixi.test.ts` proves by hand for the
883
- * three/pixi pair. Worlds MOUNT in `worlds` ARRAY order (GAME-ROOT-DESIGN §4's
715
+ * three/pixi pair. Worlds MOUNT in `roots` ARRAY order (GAME-ROOT-DESIGN §4's
884
716
  * "manifest declaration order" — the frame/registration axis), independent of
885
717
  * `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
886
718
  * and both are honored correctly.
887
719
  */
888
720
 
889
721
  /**
890
- * Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the worlds
722
+ * Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the roots
891
723
  * path's DEFAULT world (E4) — split out of `createWorldsGameRuntime` purely
892
724
  * to keep that function's own cyclomatic complexity down. Mirrors the
893
725
  * legacy single-world path's identical exposure (above, in this same file),
@@ -923,15 +755,15 @@ interface MountAllWorldsInputs extends Omit<OneWorldContext, 'canvas' | 'isBotto
923
755
  }
924
756
 
925
757
  interface MountAllWorldsResult {
926
- readonly mountedEntries: MountedWorldEntry[];
927
- readonly routerEntries: RouterWorldEntry[];
758
+ readonly mountedEntries: MountedAdapterRoot[];
759
+ readonly routerEntries: RouterAdapterRoot[];
928
760
  }
929
761
 
930
762
  /**
931
763
  * Mount + register every world spec, in ARRAY (declaration) order — split
932
764
  * out of `createWorldsGameRuntime` purely to keep that function's own
933
765
  * cyclomatic complexity down (E4, same reason `mountOneThreeWorld`/
934
- * `mountOnePixiWorld` are already split out). React worlds contribute NO
766
+ * `mountOnePixiWorld` are already split out). React roots contribute NO
935
767
  * router entry (docs/REACT-WORLD-DESIGN.md §1.C — "DOM layers need no entry
936
768
  * in the router's hit-test loop"): their layer's own `pointer-events`
937
769
  * discipline handles claim/fall-through natively, with zero router
@@ -943,8 +775,8 @@ async function mountAllWorldSpecs(
943
775
  inputs: MountAllWorldsInputs,
944
776
  ): Promise<MountAllWorldsResult> {
945
777
  const { surfacesById, ...shared } = inputs;
946
- const mountedEntries: MountedWorldEntry[] = [];
947
- const routerEntries: RouterWorldEntry[] = [];
778
+ const mountedEntries: MountedAdapterRoot[] = [];
779
+ const routerEntries: RouterAdapterRoot[] = [];
948
780
 
949
781
  for (const spec of mountSpecs) {
950
782
  const isBottom = spec.id === bottomId;
@@ -956,15 +788,15 @@ async function mountAllWorldSpecs(
956
788
  }
957
789
  const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
958
790
  const oneCtx: OneWorldContext = { ...shared, canvas, isBottom };
959
- let mountedEntry: MountedWorldEntry;
960
- let routerEntry: RouterWorldEntry;
791
+ let mountedEntry: MountedAdapterRoot;
792
+ let routerEntry: RouterAdapterRoot;
961
793
  if (spec.kind === 'threejs') {
962
794
  ({ mountedEntry, routerEntry } = await mountOneThreeWorld(spec, oneCtx));
963
795
  } else if (spec.kind === 'pixijs') {
964
796
  ({ mountedEntry, routerEntry } = await mountOnePixiWorld(spec, oneCtx));
965
797
  } else {
966
798
  // Exhaustiveness guard (§7.4-2): 'react' was already handled by the
967
- // early `continue` above, so only a hypothetical 4th `WorldKind` can
799
+ // early `continue` above, so only a hypothetical 4th `AdapterSurface` can
968
800
  // reach here — fail loudly rather than silently defaulting. `spec`
969
801
  // itself (not `spec.kind`) is what TS has narrowed to `never`, since
970
802
  // `WorldMountSpec` is a discriminated union at the object level.
@@ -977,14 +809,20 @@ async function mountAllWorldSpecs(
977
809
  }
978
810
 
979
811
  async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
980
- const { container, worlds: specs, width, height, headless = false } = config;
812
+ const { container, roots: specs, width, height, headless = false, seed } = config;
981
813
  if (specs.length === 0) {
982
- throw new Error('createGameRuntime: `worlds` must contain at least one world.');
814
+ throw new Error('createGameRuntime: `roots` must contain at least one root.');
983
815
  }
984
816
  const mountSpecs = specs as (ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec)[];
985
817
 
986
- const w = width ?? (container as HTMLElement & { clientWidth?: number }).clientWidth ?? 0;
987
- const h = height ?? (container as HTMLElement & { clientHeight?: number }).clientHeight ?? 0;
818
+ const w = Math.max(
819
+ 1,
820
+ width ?? (container as HTMLElement & { clientWidth?: number }).clientWidth ?? 0,
821
+ );
822
+ const h = Math.max(
823
+ 1,
824
+ height ?? (container as HTMLElement & { clientHeight?: number }).clientHeight ?? 0,
825
+ );
988
826
  const dpr = headless
989
827
  ? 1
990
828
  : Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2);
@@ -1026,18 +864,19 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1026
864
  // too (not just in `mountOneThreeWorld`) means every surface, whatever
1027
865
  // kind, starts container-relative from its very first paint, before any
1028
866
  // per-kind mount work has even run.
1029
- surface.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;z-index:${i + 1};`;
867
+ // `contain:layout paint` makes each surface the CONTAINING BLOCK for
868
+ // `position:fixed` descendants (and clips overflow to the world's
869
+ // rectangle): full-screen game UI written the natural way (`fixed;
870
+ // inset:0`) then fills the WORLD, not the page. Without it, `fixed` UI
871
+ // looks correct standalone (surface == window) but escapes over the
872
+ // editor chrome whenever the surface is a sub-rectangle of the page —
873
+ // and toggles behavior when any ancestor gains a transform (e.g. the
874
+ // editor's space-pan). Dogfooded 2026-07-12 via a react shell world.
875
+ surface.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;z-index:${i + 1};contain:layout paint;`;
1030
876
  container.appendChild(surface);
1031
877
  surfacesById.set(entry.id, surface);
1032
878
  });
1033
879
 
1034
- // One shared UI overlay, above every canvas.
1035
- const uiContainer = document.createElement('div');
1036
- uiContainer.style.cssText =
1037
- `position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;` +
1038
- `z-index:${stacked.length + 1};`;
1039
- container.appendChild(uiContainer);
1040
-
1041
880
  const assets = createAssetCache();
1042
881
  const extraUpdaters = new Set<(dt: number) => void>();
1043
882
  const loopHandle: LoopHandle = {
@@ -1055,7 +894,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1055
894
  for (const fn of extraUpdaters) fn(dt);
1056
895
  },
1057
896
  });
1058
- const game = createGame({ loop, assets });
897
+ const game = createGame({ loop, assets, seed });
1059
898
 
1060
899
  // --- Mount + register every world, in ARRAY (declaration) order. ---
1061
900
  // Split out into its own top-level function purely to keep
@@ -1069,7 +908,6 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1069
908
  h,
1070
909
  dpr,
1071
910
  headless,
1072
- uiContainer,
1073
911
  loopHandle,
1074
912
  assets,
1075
913
  });
@@ -1082,7 +920,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1082
920
 
1083
921
  // Expose scene & camera for dev tools / e2e tests — mirrors the legacy
1084
922
  // single-world path's identical dev-only exposure above, generalized to
1085
- // the worlds path's DEFAULT world (E4, docs/unified-world-editor/
923
+ // the roots path's DEFAULT world (E4, docs/unified-world-editor/
1086
924
  // 27-visual-react-editing.md §7 E4): `mountGameFromManifest`/
1087
925
  // `mountManifestWorlds` route every caller (including a single-threejs-
1088
926
  // world scaffold project) through THIS path, so a caller migrating off
@@ -1102,7 +940,6 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1102
940
  entry.renderer?.forceContextLoss();
1103
941
  container.removeChild(entry.element);
1104
942
  }
1105
- container.removeChild(uiContainer);
1106
943
  retractDevGlobals();
1107
944
  }
1108
945
 
@@ -1124,9 +961,11 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1124
961
  game.play.step();
1125
962
  },
1126
963
  resize(rw: number, rh: number) {
964
+ const safeWidth = Math.max(1, rw);
965
+ const safeHeight = Math.max(1, rh);
1127
966
  for (const entry of mountedEntries) {
1128
- entry.renderer?.setSize(rw, rh, false);
1129
- entry.mounted.resize?.(rw, rh);
967
+ entry.renderer?.setSize(safeWidth, safeHeight, false);
968
+ entry.mounted.resize?.(safeWidth, safeHeight);
1130
969
  }
1131
970
  },
1132
971
  // `GameSession.scene`/`.camera`/`.mounted` alias `Game.defaultWorld` (T6.1
@@ -1134,7 +973,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
1134
973
  // convenience shaped for the threejs-only past. T7.5 narrows the read via
1135
974
  // the `kind` discriminant instead of a blind `.scene`/`.camera` cast
1136
975
  // through a nonexistent property (identical behavior to before: still
1137
- // `undefined` for a worlds-path session whose default world isn't
976
+ // `undefined` for a roots-path session whose default world isn't
1138
977
  // threejs — real per-world surface routing for that case is T7.6's).
1139
978
  get scene() {
1140
979
  const m = game.defaultWorld.mounted;