@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
@@ -8,8 +8,8 @@
8
8
  * threejs/scene check) and the two-world (threejs + pixijs) proof
9
9
  * (`test/game-three-plus-pixi.test.ts`). T6.2 slice 1 adds real `'react'`-kind
10
10
  * `WorldInstance` support (`container`, a real `reactRoot()`) — the DOM-root
11
- * world surface `runtime/create-runtime.ts`'s worlds path now mounts (see
12
- * `docs/REACT-WORLD-DESIGN.md` §1.B); react worlds host no `ComponentManager`
11
+ * world surface `runtime/create-runtime.ts`'s roots path now mounts (see
12
+ * `docs/REACT-WORLD-DESIGN.md` §1.B); react roots host no `ComponentManager`
13
13
  * (D8 — components attaching to a react-kind manager already throw,
14
14
  * `ecs/component-manager.ts`), so `physics`/`collisions`/`camera`/`frame`
15
15
  * stay `undefined` for them exactly like an opaque/foreign mount.
@@ -29,15 +29,21 @@
29
29
  // at compile time; nothing here constructs or calls into Pixi.
30
30
  import type * as PIXI from 'pixi.js';
31
31
  import type * as THREE from 'three';
32
+ import type { AdapterSurface as AdapterSurfaceLeaf } from '../adapter/adapter-surface';
32
33
  import type { MountedWorld } from '../adapter/game-adapter';
33
34
  import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
34
35
  import type { SystemAdapters } from '../adapter/system-adapter';
35
36
  import type { VgaiMountedGame } from '../adapter/vgai-scene-game-adapter';
36
- import type { WorldKind as WorldKindLeaf } from '../adapter/world-kind';
37
37
  import type { AssetCache } from '../assets';
38
38
  import type { createGameLoop } from '../core/game-loop';
39
+ import {
40
+ createSeededRandom,
41
+ DEFAULT_SEEDED_RANDOM_SEED,
42
+ registerSeededRandom,
43
+ } from '../core/seeded-random';
39
44
  import { createSystemRunner, type SystemRunner } from '../core/system-runner';
40
45
  import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from '../core/types';
46
+ import { createPerformanceProfiler, type PerformanceProfiler } from '../dev/performance-profiler';
41
47
  import type { ComponentManager } from '../ecs/component-manager';
42
48
  import type { GameComponent } from '../ecs/game-component';
43
49
  import type { InputManager } from '../input/input-manager';
@@ -48,6 +54,13 @@ import type { AudioContext as GameAudio } from '../setup/setup-audio';
48
54
  // `world2d/`, but `game.ts` only ever names their TYPES.
49
55
  import type { Collision2DSystem } from '../world2d/collision-2d';
50
56
  import type { Physics2DRegistry } from '../world2d/physics2d-registry';
57
+ import {
58
+ createDebugRegistry,
59
+ DebugError,
60
+ type RunTicksOptions,
61
+ registerDebugRegistry,
62
+ } from './debug-registry';
63
+ import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
51
64
  import { createStateBridge, type GameStateBridge } from './state-bridge';
52
65
 
53
66
  /** The one loop type — `createGameLoop`'s return shape (D1, fixed-step). */
@@ -92,8 +105,8 @@ export interface PlayState {
92
105
  * undo for it). */
93
106
  resume(): void;
94
107
  /**
95
- * Advance exactly the currently-FROZEN worlds by one fixed substep: the
96
- * `pausable`, host-driven (`!drivesOwnLoop`) worlds while `paused` is
108
+ * Advance exactly the currently-FROZEN roots by one fixed substep: the
109
+ * `pausable`, host-driven (`!drivesOwnLoop`) roots while `paused` is
97
110
  * true. While NOT paused this is a whole-call no-op — under D10 the loop
98
111
  * never stops, so every non-frozen world is already being ticked; an
99
112
  * unconditional extra tick was the §7.1-2 double-tick bug (probe4). A
@@ -109,18 +122,18 @@ export interface PlayState {
109
122
 
110
123
  /**
111
124
  * The kinds of render surface a world can be. `'threejs'` and `'pixijs'`
112
- * worlds are real as of T7.3 slice 1 (`create-runtime.ts`'s default threejs
125
+ * roots are real as of T7.3 slice 1 (`create-runtime.ts`'s default threejs
113
126
  * world; `world2d/pixi-game-adapter.ts`'s pixi world); `'react'` is named
114
127
  * here so the type is already shaped for T7.4 and no caller has to widen a
115
128
  * union later.
116
129
  *
117
- * Re-exported from `adapter/world-kind.ts` (T7.5) — moved there so
130
+ * Re-exported from `adapter/adapter-surface.ts` (T7.5) — moved there so
118
131
  * `adapter/game-adapter.ts`'s kind-tagged `MountedWorld` types can name it
119
132
  * without an import cycle back to this file. This re-export keeps every
120
- * existing `import type { WorldKind } from '../runtime/game'` call site
133
+ * existing `import type { AdapterSurface } from '../runtime/game'` call site
121
134
  * (`ecs/game-component.ts`, `ecs/component-manager.ts`) compiling unchanged.
122
135
  */
123
- export type WorldKind = WorldKindLeaf;
136
+ export type AdapterSurface = AdapterSurfaceLeaf;
124
137
 
125
138
  /**
126
139
  * A world's per-phase frame hooks (GAME-ROOT-DESIGN §4, T7.1 slice 2).
@@ -134,7 +147,7 @@ export interface WorldFrameHooks {
134
147
  * systems for one phase. For a first-party world this delegates to the
135
148
  * SAME `SystemRunner.runPhase` its (legacy) `mounted.update` uses. */
136
149
  runPhase(phase: SystemPhaseName, dt: number): void;
137
- /** Run once per substep, after ALL phases have run for ALL worlds this
150
+ /** Run once per substep, after ALL phases have run for ALL roots this
138
151
  * substep (mirrors where `mounted.update`'s post-`systems.run` work sat
139
152
  * today — e.g. `input.endFrame()`). Optional: a world may have nothing
140
153
  * to do here. */
@@ -149,7 +162,7 @@ export interface WorldFrameHooks {
149
162
  export interface WorldInstance {
150
163
  /** Manifest id (T3.1). Slice 1 always registers exactly one: `'main'`. */
151
164
  readonly id: string;
152
- readonly kind: WorldKind;
165
+ readonly kind: AdapterSurface;
153
166
  /** Per-world play/pause semantics (D10, T7.6). Slice 1 always `true`. */
154
167
  readonly pausable: boolean;
155
168
  /** The GameAdapter that produced `mounted` — first-party or external.
@@ -169,12 +182,12 @@ export interface WorldInstance {
169
182
  /** Kind-narrowed accessor: throws a descriptive error when this world is
170
183
  * not a threejs world. */
171
184
  threeRoot(): THREE.Scene;
172
- /** Kind-narrowed accessor for pixijs worlds (T7.3): returns the stage
185
+ /** Kind-narrowed accessor for pixijs roots (T7.3): returns the stage
173
186
  * passed to `createWorldInstance` for a `'pixijs'`-kind world. Throws
174
187
  * descriptively for a non-pixijs world, or a pixijs world built without
175
188
  * a `stage` (see `WorldInstanceInit.stage`). */
176
189
  pixiRoot(): PIXI.Container;
177
- /** Kind-narrowed accessor for react worlds (T6.2 slice 1,
190
+ /** Kind-narrowed accessor for react roots (T6.2 slice 1,
178
191
  * `docs/REACT-WORLD-DESIGN.md` §1.B): returns the DOM-root layer
179
192
  * `<div>` the host mounted this world's react tree into (the SAME
180
193
  * element passed as `container` to `createWorldInstance` — identity
@@ -184,7 +197,7 @@ export interface WorldInstance {
184
197
  * `WorldInstanceInit.container`). */
185
198
  reactRoot(): HTMLElement;
186
199
  /** Present only when the world's mount is first-party (Rapier3D for
187
- * threejs worlds). */
200
+ * threejs roots). */
188
201
  readonly physics?: PhysicsRegistry | undefined;
189
202
  readonly collisions?: CollisionSystem | undefined;
190
203
  /** Present only when the world's mount is a first-party pixijs world
@@ -208,7 +221,7 @@ export interface WorldInstance {
208
221
  * Minimal identity surface `WorldInstance.adapter` needs (T7.5) — see that
209
222
  * field's doc comment for why it's narrower than `GameAdapter<K>`. Any real
210
223
  * adapter object (a NAMED type, not a fresh object literal) — `GameAdapter<K>`,
211
- * `Pixi2DGameAdapter`, `ReactWorldAdapter`, or a project's own custom adapter —
224
+ * `Pixi2DGameAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
212
225
  * satisfies this trivially (extra members beyond `id` are always fine for a
213
226
  * non-literal source); a bare `{ id, mount }` object literal built INLINE at
214
227
  * a `createWorldInstance`/`registerXWorld` call site needs an intermediate
@@ -221,7 +234,7 @@ export interface AdapterHandle {
221
234
  /** Inputs to {@link createWorldInstance}. */
222
235
  export interface WorldInstanceInit {
223
236
  readonly id: string;
224
- readonly kind: WorldKind;
237
+ readonly kind: AdapterSurface;
225
238
  /** Defaults to `true` (D10's per-world default). */
226
239
  readonly pausable?: boolean;
227
240
  readonly adapter: AdapterHandle;
@@ -300,10 +313,10 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
300
313
  }
301
314
 
302
315
  // Disposed-world guard (GAME-ROOT-DESIGN §8 stage 3 / T7.1 slice 3). There
303
- // is no `unregisterWorld` (decision 4 — worlds are manifest-declared; a
316
+ // is no `unregisterWorld` (decision 4 — roots are manifest-declared; a
304
317
  // whole Game is disposed, not one world out of its registry), so a caller
305
318
  // that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
306
- // leaves that `WorldInstance` sitting in `Game.worlds` — and `runFrame`
319
+ // leaves that `WorldInstance` sitting in `Game.roots` — and `runFrame`
307
320
  // would otherwise keep invoking its (now-torn-down) frame hooks every
308
321
  // subsequent frame. `MountedGame` has no public "am I disposed" flag to
309
322
  // read, so this wraps `mounted.dispose` in place (mutating the SAME mount
@@ -393,7 +406,7 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
393
406
  * mounted` — world2d's `MountedGame2D` (`world2d/pixi-game-adapter.ts`) also
394
407
  * has a `ctx` key (a `World2DContext`, unrelated to `GameContext`), so a
395
408
  * structural `'ctx' in mounted` check would misfire once T7.3 registers 2D
396
- * worlds onto the same `Game`. The probe is a plain property read, not an
409
+ * roots onto the same `Game`. The probe is a plain property read, not an
397
410
  * `instanceof`/value import of `vgai-scene-game-adapter.ts` — the
398
411
  * `VgaiMountedGame` import above stays type-only.
399
412
  */
@@ -413,6 +426,8 @@ export function isFirstPartyMounted(mounted: MountedWorld): mounted is VgaiMount
413
426
  export interface Game {
414
427
  readonly loop: GameLoop;
415
428
  readonly assets: AssetCache;
429
+ /** Per-game diagnostic store. Disabled by default; the editor enables it on demand. */
430
+ readonly profiler: PerformanceProfiler;
416
431
  /**
417
432
  * The game-scoped `SystemRunner` (GAME-ROOT-DESIGN §4, T7.1 slice 2) — a
418
433
  * NEW bucket, separate from any world's own runner. Within each phase,
@@ -426,9 +441,9 @@ export interface Game {
426
441
  readonly systems: SystemRunner;
427
442
  /** Declaration-ordered. Slice 1 registers exactly one (the default
428
443
  * threejs world) — this is the SAME array reference `registerWorld`
429
- * mutates, not a snapshot, so holders (e.g. `GameContext.worlds`) observe
444
+ * mutates, not a snapshot, so holders (e.g. `GameContext.roots`) observe
430
445
  * later registrations. */
431
- readonly worlds: ReadonlyArray<WorldInstance>;
446
+ readonly roots: ReadonlyArray<WorldInstance>;
432
447
  world(id: string): WorldInstance | null;
433
448
  /** First threejs world, else first world. Throws descriptively when no
434
449
  * world has been registered yet. */
@@ -440,9 +455,9 @@ export interface Game {
440
455
  * mounted world builds its OWN `mounted.systems` object (a game registers
441
456
  * capabilities like `networking` from ITS OWN `setup()`, via
442
457
  * `ctx.registerSystemAdapter`, per-world) — this merges every world's
443
- * `mounted.systems` into ONE `SystemAdapters`, in `worlds` REGISTRATION
458
+ * `mounted.systems` into ONE `SystemAdapters`, in `roots` REGISTRATION
444
459
  * order, first registration wins per key. A later world registering the
445
- * SAME kind (e.g. two worlds both exposing `networking`) does not
460
+ * SAME kind (e.g. two roots both exposing `networking`) does not
446
461
  * override the first — instead this warns ONCE per (game instance, key)
447
462
  * naming both world ids, matching this file's `[game] world "<id>" …`
448
463
  * console idiom (see `registerWorld` below). NOT to be confused with
@@ -470,9 +485,9 @@ export interface Game {
470
485
  /**
471
486
  * Frame-versioned state bridge (T7.4 slice 1 — `docs/REACT-STATE-BRIDGE.md`
472
487
  * §2). Bumped once per completed `runFrame`, after all phases of all
473
- * worlds and all `endFrame` hooks (see `runFrame`'s tail below). This is
488
+ * roots and all `endFrame` hooks (see `runFrame`'s tail below). This is
474
489
  * the ONE subscription surface `useGameState`
475
- * (`packages/editor/template/src/ui/game-state.tsx`) — or any
490
+ * (`@vgai/engine/react/game-state`) — or any
476
491
  * other frame-versioned consumer — subscribes to; `state-bridge.ts` itself
477
492
  * has no react import, matching the rest of `runtime/`.
478
493
  */
@@ -484,7 +499,7 @@ export interface Game {
484
499
  * game-level half; `ComponentManager.queryByComponent` (`ecs/
485
500
  * component-manager.ts`) is the per-world half this aggregates.
486
501
  *
487
- * Iterates `worlds` in declaration order, skipping any world whose mount
502
+ * Iterates `roots` in declaration order, skipping any world whose mount
488
503
  * is not first-party (an opaque/foreign mount has no `ComponentManager`
489
504
  * to query), applying `opts.worldId`/`opts.kind` as world-level filters,
490
505
  * and concatenating each remaining world's `queryByComponent(cls)`
@@ -502,15 +517,15 @@ export interface Game {
502
517
  * signature keep working unchanged once the manager is unified
503
518
  * underneath.
504
519
  *
505
- * `T extends GameComponent<WorldKind>` (not the bare default-`'threejs'`
520
+ * `T extends GameComponent<AdapterSurface>` (not the bare default-`'threejs'`
506
521
  * `GameComponent`, T7.3 slice 2) — the whole point of a CROSS-WORLD query
507
522
  * is spanning every kind in one call (the T7.3 AC,
508
523
  * `test/game-three-plus-pixi.test.ts`), so a `GameComponent<'pixijs'>`
509
524
  * subclass must type-check here exactly like a default-kind one does.
510
525
  */
511
- queryByComponent<T extends GameComponent<WorldKind>>(
526
+ queryByComponent<T extends GameComponent<AdapterSurface>>(
512
527
  cls: new () => T,
513
- opts?: { worldId?: string; kind?: WorldKind },
528
+ opts?: { worldId?: string; kind?: AdapterSurface },
514
529
  ): T[];
515
530
  }
516
531
 
@@ -532,10 +547,10 @@ export interface GameInternal extends Game {
532
547
  * ```
533
548
  * for phase in PHASE_ORDER:
534
549
  * game.systems.runPhase(phase, dt) // game-scoped, first
535
- * for world in worlds (declaration order):
550
+ * for world in roots (declaration order):
536
551
  * if world.mounted.drivesOwnLoop: continue
537
552
  * world.frame?.runPhase(phase, dt)
538
- * for world in worlds: // after ALL phases
553
+ * for world in roots: // after ALL phases
539
554
  * if world.mounted.drivesOwnLoop: continue
540
555
  * if world.frame: world.frame.endFrame?.()
541
556
  * else: world.mounted.update?.(dt) // opaque world fallback
@@ -569,6 +584,54 @@ export interface GameInternal extends Game {
569
584
  * the frozen set is then empty.
570
585
  */
571
586
  runFrame(dt: number, opts?: { ignorePause?: boolean }): void;
587
+ /**
588
+ * D15/T-D15.3-.4 (`docs/D15-DETERMINISM-DESIGN.md` §2.b) — a deterministic
589
+ * fast-forward primitive: synchronously call the SAME per-tick pipeline
590
+ * `runFrame` uses, `n` times in a tight loop, with `dt` fixed to the host
591
+ * loop's own fixed timestep (`this.loop.fixedDt` — `core/game-loop.ts`).
592
+ * This generalizes `render-control.ts`'s proven `VgaiRenderHarness.
593
+ * simulateSubsteps` from the capture-only door (`?vgai-render=1`) to a
594
+ * Game-level primitive every door can reach (the bridge's
595
+ * `window.__vgai.runTicks`, the editor relay's `run-ticks` case →
596
+ * `play.runTicks`) — `simulateSubsteps` itself is UNTOUCHED by this
597
+ * addition (it may later delegate to this method; not this unit's job).
598
+ *
599
+ * Semantics:
600
+ * - **Decoupled from wall clock and the accumulator.** `runTicks` drives
601
+ * `runFrame` DIRECTLY — it never goes through `GameLoop`'s own
602
+ * accumulator/spiral-of-death drop path, so `n` ticks always advance
603
+ * `tick`/`simT` by exactly `n`/`n * fixedDt`, on any hardware, regardless
604
+ * of how slow or fast the call actually took wall-clock-wise. Single-
605
+ * threaded-burst note: because JS has one thread, this synchronous burst
606
+ * can never interleave with a real rAF frame — but if the host's own
607
+ * loop is still running (`loop.start()`), wall-clock time keeps accruing
608
+ * in ITS accumulator while this call executes; the NEXT rAF frame after
609
+ * the burst sees that gap and the loop's existing `maxAccumulator` clamp
610
+ * absorbs it exactly as it would absorb any other slow-frame gap (up to
611
+ * 8 substeps, additional time dropped) — `runTicks` does not need to
612
+ * (and does not) touch the accumulator itself to make this safe.
613
+ * - **`opts.render`** (default `'last'`): `'last'` skips the `preRender`/
614
+ * `render` phases for ticks `0..n-2` and runs the full phase list
615
+ * (including `preRender`/`render`) on the final tick only — the GGPO
616
+ * tick-without-render pattern. `'all'` renders every tick. `'none'`
617
+ * never renders, not even the last tick. `tick`/`simT`/
618
+ * `stateBridge.bump()`/the debug event ring advance identically on
619
+ * EVERY tick regardless of `render` — only the paint-affecting phases
620
+ * are skipped, so state watchers (`useGameState`, debug state providers)
621
+ * stay correct even when fast-forwarding with no visible output.
622
+ * - **Refuses while paused.** Throws a structured `DebugError`
623
+ * (`code: 'RUN_TICKS_PAUSED'`) if `Game.play.paused` is true —
624
+ * `Game.play.step()` owns stepping the frozen set; `runTicks` is a
625
+ * running-game primitive, not a paused-world stepper, and silently
626
+ * no-op-ing or silently ignoring pause would violate the "byte-identical
627
+ * across doors" contract this primitive exists to provide.
628
+ * - **Does not bypass the input focus gate.** Every tick's `input` phase
629
+ * runs through the SAME `InputManager` every other call to `runFrame`
630
+ * does — a gated virtual actuation (page unfocused, Game tab inactive)
631
+ * still reports `{delivered: false, reason}` exactly as it would under
632
+ * normal play; `runTicks` has no special-cased "force focus" behavior.
633
+ */
634
+ runTicks(n: number, opts?: RunTicksOptions): void;
572
635
  }
573
636
 
574
637
  function describeMismatch(handle: 'components' | 'input' | 'audio', world: WorldInstance): string {
@@ -582,19 +645,86 @@ function describeMismatch(handle: 'components' | 'input' | 'audio', world: World
582
645
  /**
583
646
  * Construct the (host-internal) Game shell. Callers: `createGameRuntime`
584
647
  * builds this BEFORE mounting its one adapter, then registers the default
585
- * threejs world once mount resolves (see `registerDefaultThreeWorld` in
648
+ * threejs world once mount resolves (see `registerThreeWorld` in
586
649
  * `create-runtime.ts`).
587
650
  */
588
- export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameInternal {
589
- const worlds: WorldInstance[] = [];
590
- const systems = createSystemRunner();
651
+ export function createGame(opts: {
652
+ loop: GameLoop;
653
+ assets: AssetCache;
654
+ /** D15 (T-D15.1) — the root seed `ctx.random` boots from, on every world
655
+ * mounted onto this Game. Defaults to `DEFAULT_SEEDED_RANDOM_SEED` (a
656
+ * fixed, non-wall-clock constant — `ctx.random` is always reproducible
657
+ * on its own terms, whether or not the project's manifest DECLARES that
658
+ * reproducibility as a contract). The manifest-aware boot path
659
+ * (`mount-manifest.ts`'s `mountManifestWorlds`) is what actually resolves
660
+ * `manifest.determinism.defaultSeed`/`?vgai-seed=`/explicit config and
661
+ * passes the result here, BEFORE any world's `mount()`/`setup()` runs —
662
+ * `createGameRuntime` always constructs the Game (this call) first (see
663
+ * this function's own doc comment below). */
664
+ seed?: number | undefined;
665
+ }): GameInternal {
666
+ const roots: WorldInstance[] = [];
667
+ const profiler = createPerformanceProfiler();
668
+ const systems = createSystemRunner(profiler.systemObserver, 'game');
591
669
  const stateBridge = createStateBridge();
670
+ // D15 (T-D15.1) — the game-scoped seeded-random surface every world's
671
+ // `ctx.random` aliases (see `vgai-scene-game-adapter.ts`'s `ctx.random =
672
+ // ...`, wired the same way `ctx.debug` is just below). Constructed
673
+ // unconditionally (cheap — a handful of closures) regardless of whether
674
+ // this project ever declares `determinism.seededRandom`; only the BOOT
675
+ // SEED and the enforcement (the burn-down scan, the trap right below) are
676
+ // conditional on that declaration.
677
+ const seededRandom = createSeededRandom(opts.seed ?? DEFAULT_SEEDED_RANDOM_SEED);
678
+ // D15 (T-D15.3) — the dev-mode `Math.random` phase trap. Constructed
679
+ // unconditionally too (disabled by default: `rngTrapEnabled` starts
680
+ // `false`, so `runFrameImpl`'s enable/disable calls below are no-ops) —
681
+ // the manifest-aware boot path flips `rngTrapControl.setEnabled(true)`
682
+ // once it resolves `manifest.determinism?.seededRandom` (mirrors
683
+ // `debugRegistry.setRoomDeclared` being flipped post-hoc from the same
684
+ // boot path for the very same "only the caller who read the manifest
685
+ // knows" reason).
686
+ const rngTrap = createGameplayRngTrap();
687
+ let rngTrapEnabled = false;
688
+ const rngTrapControl = {
689
+ setEnabled(enabled: boolean): void {
690
+ rngTrapEnabled = enabled;
691
+ },
692
+ get enabled(): boolean {
693
+ return rngTrapEnabled;
694
+ },
695
+ };
696
+ // T1.2 (docs/SYNTHETIC-PLAYER-SPEC.md §3.1/§3.3): `tick` counts completed
697
+ // fixed substeps, `simT` accumulates their `dt` — both game-scoped, bumped
698
+ // ONLY where `stateBridge.bump()` is (guarded by `advanced`, `runFrame`'s
699
+ // tail below), so a paused/frozen frame never advances either. The debug
700
+ // registry reads them via suppliers (not by capturing the numbers now)
701
+ // so its built-in `time` provider always sees the CURRENT values.
702
+ let tick = 0;
703
+ let simT = 0;
704
+ const debugRegistry = createDebugRegistry({
705
+ getTick: () => tick,
706
+ getSimT: () => simT,
707
+ // D15/T-D15.5 — "the manifest's first/default world" for the per-world
708
+ // input-target resolution (`resolveInputWorldId`, `debug-registry.ts`):
709
+ // the SAME "first threejs world, else first world" rule
710
+ // `requireDefaultWorld` (declared just below — safe: this closure is
711
+ // only ever CALLED later, once at least one world has mounted) already
712
+ // defines for `Game.defaultWorld`/`Game.input` — so the debug bridge and
713
+ // the editor relay resolve the exact same world `Game.input` would.
714
+ getDefaultWorldId: () => (roots.length > 0 ? requireDefaultWorld().id : null),
715
+ // Issue #175 — the built-in `time` provider's `loopLiveness` field reads
716
+ // the REAL loop, not any UI-level play-state store: `opts.loop` is the
717
+ // SAME `GameLoop` this `Game`'s own `.loop` field exposes, so this
718
+ // registry can never disagree with `game.loop.liveness` about whether
719
+ // the loop is actually ticking.
720
+ getLoopLiveness: () => opts.loop.liveness,
721
+ });
592
722
 
593
723
  function requireDefaultWorld(): WorldInstance {
594
- if (worlds.length === 0) {
595
- throw new Error('Game.defaultWorld: no worlds registered yet');
724
+ if (roots.length === 0) {
725
+ throw new Error('Game.defaultWorld: no roots registered yet');
596
726
  }
597
- return worlds.find((w) => w.kind === 'threejs') ?? worlds[0]!;
727
+ return roots.find((w) => w.kind === 'threejs') ?? roots[0]!;
598
728
  }
599
729
 
600
730
  function requireFirstPartyCtx(handle: 'components' | 'input' | 'audio') {
@@ -613,15 +743,26 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
613
743
  const warnedSystemAdapterKeys = new Set<string>();
614
744
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
615
745
  function computeSystemAdapters(): SystemAdapters {
616
- const result: SystemAdapters = {};
617
- const ownerWorldId = new Map<string, string>();
618
- for (const world of worlds) {
746
+ // Debug is game-scoped: React hooks, probes, and the built-in time
747
+ // provider all register with the one registry created above, regardless
748
+ // of whether any mounted root happens to expose a `systems` object. A
749
+ // root may still publish this SAME adapter (the first-party Three/Pixi
750
+ // mounts do); the reference-equality branch below treats that as the
751
+ // intentional shared registration it is.
752
+ const result: SystemAdapters = { debug: debugRegistry.adapter };
753
+ const ownerWorldId = new Map<string, string>([['debug', '(game)']]);
754
+ for (const world of roots) {
619
755
  const adapters = world.mounted.systems;
620
756
  if (!adapters) continue;
621
757
  for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
622
758
  if (adapters[key] === undefined) continue;
623
759
  const existingOwner = ownerWorldId.get(key);
624
760
  if (existingOwner !== undefined) {
761
+ // Reference-equality short-circuit: two roots sharing the ONE
762
+ // game-scoped debug registry's adapter (T1.1) both expose the SAME
763
+ // object under `systems.debug` — that is by design, not a
764
+ // collision, so it must never warn.
765
+ if (result[key] === adapters[key]) continue;
625
766
  if (!warnedSystemAdapterKeys.has(key)) {
626
767
  warnedSystemAdapterKeys.add(key);
627
768
  // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
@@ -665,14 +806,14 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
665
806
  console.warn(message);
666
807
  }
667
808
 
668
- /** Fan out a loop-gate (self-driven worlds) + audio-gate call over every
809
+ /** Fan out a loop-gate (self-driven roots) + audio-gate call over every
669
810
  * `pausable` world, reporting honestly (once) wherever the capability is
670
811
  * absent — shared by `pause()`/`resume()` below (same fan-out, opposite
671
812
  * boolean). */
672
813
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fans out TWO independent capability gates (loop, audio) with the same "call it, else report once" shape per world — splitting the two gates into separate loops would duplicate the fan-out, not reduce real complexity
673
814
  function setWorldGates(next: boolean): void {
674
- for (const world of worlds) {
675
- if (!world.pausable) continue; // pausable:false worlds are untouched by design
815
+ for (const world of roots) {
816
+ if (!world.pausable) continue; // pausable:false roots are untouched by design
676
817
  if (world.mounted.drivesOwnLoop) {
677
818
  if (world.mounted.setPaused) {
678
819
  world.mounted.setPaused(next);
@@ -693,12 +834,26 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
693
834
  }
694
835
  }
695
836
 
696
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (GAME-ROOT-DESIGN §4, now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/worlds — splitting it would obscure the ordering contract documented on GameInternal.runFrame
837
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (GAME-ROOT-DESIGN §4, now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/roots — splitting it would obscure the ordering contract documented on GameInternal.runFrame
697
838
  function runFrameImpl(
698
839
  dt: number,
699
- frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean },
840
+ frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean; skipRenderPhases?: boolean },
700
841
  ): void {
842
+ // D15 (T-D15.3) — brackets the ENTIRE frame body (every phase, every
843
+ // world, both the `onlyFrozen` and normal branches below converge on the
844
+ // single `profiler.endFrame()` at the tail) with zero reordering of the
845
+ // phase algorithm itself — a no-op pair of calls while
846
+ // `rngTrapEnabled` is false (the common case: most projects never
847
+ // declare `determinism.seededRandom`).
848
+ if (rngTrapEnabled) rngTrap.enable();
849
+ profiler.beginFrame();
701
850
  const ignorePause = frameOpts?.ignorePause ?? false;
851
+ // D15/T-D15.4: internal-only render-phase skip (not part of the public
852
+ // `GameInternal.runFrame` signature — no external caller sets it, mirroring
853
+ // `onlyFrozen` below) — `runTicks`'s `render: 'none'|'last'` fast-forward
854
+ // mode sets this on every tick it doesn't want to paint. See `runTicks`'s
855
+ // doc comment on `GameInternal` for the full contract.
856
+ const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
702
857
  // `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
703
858
  // signature — no external caller sets it) — `Game.play.step()` below is the
704
859
  // one and only caller. It ticks EXACTLY the currently-frozen set (host-driven,
@@ -711,25 +866,25 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
711
866
  // Checklist item 7: snapshot the world count ONCE at entry and iterate
712
867
  // by index in both loops below. A world registered mid-frame (e.g. from
713
868
  // a game-scoped system's side effect) joins at the NEXT `runFrame` call,
714
- // not this one — `for (const world of worlds)` would otherwise pick up
869
+ // not this one — `for (const world of roots)` would otherwise pick up
715
870
  // a world pushed during this very frame. This also drops the two
716
871
  // per-phase `for...of` iterator allocations.
717
- const n = worlds.length;
872
+ const n = roots.length;
718
873
 
719
874
  // §7.1-11 fix (probe5): `stateBridge.bump()` must fire iff at least one
720
875
  // world actually advanced this call — not unconditionally. `advanced`
721
- // covers rule (a) below (host-driven worlds this call actually ticked);
876
+ // covers rule (a) below (host-driven roots this call actually ticked);
722
877
  // rule (b): a self-driven world that is RUNNING — which is every
723
878
  // self-driven world while not paused, and, while paused, the ones the
724
879
  // gate can't reach (`pausable: false`, or no `setPaused` capability) —
725
- // checked once, up front, over the same `worlds` array (no extra
880
+ // checked once, up front, over the same `roots` array (no extra
726
881
  // allocation, matching every other loop here). Rule (b) is skipped in
727
882
  // `onlyFrozen` mode: a `step()` call bumps iff it ticked a frozen world
728
883
  // — self-driven notifications belong to the loop's own `runFrame`s.
729
884
  let advanced = false;
730
885
  if (!onlyFrozen) {
731
886
  for (let i = 0; i < n; i++) {
732
- const world = worlds[i]!;
887
+ const world = roots[i]!;
733
888
  if (!world.mounted.drivesOwnLoop) continue;
734
889
  if (!paused || !world.pausable || !world.mounted.setPaused) {
735
890
  advanced = true;
@@ -745,8 +900,9 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
745
900
  // running" behavior falls straight out of this, no separate guard
746
901
  // needed.
747
902
  for (const phase of PHASE_ORDER) {
903
+ profiler.beginPhase();
748
904
  for (let i = 0; i < n; i++) {
749
- const world = worlds[i]!;
905
+ const world = roots[i]!;
750
906
  if (world.mounted.drivesOwnLoop) continue;
751
907
  if (!(paused && world.pausable)) continue; // only the frozen set
752
908
  try {
@@ -759,9 +915,10 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
759
915
  );
760
916
  }
761
917
  }
918
+ profiler.endPhase(phase);
762
919
  }
763
920
  for (let i = 0; i < n; i++) {
764
- const world = worlds[i]!;
921
+ const world = roots[i]!;
765
922
  if (world.mounted.drivesOwnLoop) continue;
766
923
  if (!(paused && world.pausable)) continue;
767
924
  advanced = true;
@@ -787,35 +944,45 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
787
944
  }
788
945
  } else {
789
946
  for (const phase of PHASE_ORDER) {
790
- systems.runPhase(phase, dt);
791
- for (let i = 0; i < n; i++) {
792
- const world = worlds[i]!;
793
- if (world.mounted.drivesOwnLoop) continue;
794
- // D10/T7.6: a `pausable` world under an active (non-ignored) pause
795
- // skips every phase except `render` — its render still runs, every
796
- // substep, but with `dt` forced to `0` (deterministic: no
797
- // time-based render effect silently keeps animating a "frozen"
798
- // scene). A `pausable: false` world (or any world while
799
- // `ignorePause`) is unaffected.
800
- const frozen = !ignorePause && paused && world.pausable;
801
- if (frozen && phase !== SystemPhase.RENDER) continue;
802
- const phaseDt = frozen ? 0 : dt;
803
- // Checklist item 2: isolate each world's per-phase work one
804
- // world's `runPhase` throwing must not starve sibling worlds still
805
- // due this phase, nor abort the frame. Mirrors `runOne`'s style in
806
- // `core/system-runner.ts` (loud console.error, never swallowed).
807
- try {
808
- world.frame?.runPhase(phase, phaseDt);
809
- } catch (err) {
810
- console.error(
811
- `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw:`,
812
- err,
813
- );
947
+ profiler.beginPhase();
948
+ // D15/T-D15.4: `runTicks`'s fast-forward skip `preRender`/`render`
949
+ // are the ONLY phases ever skipped this way (every earlier gameplay
950
+ // phase, and `endFrame` below, always run) — see `skipRenderPhases`'s
951
+ // declaration above and `runTicks`'s doc comment on `GameInternal`.
952
+ const skipThisPhase =
953
+ skipRenderPhases && (phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER);
954
+ if (!skipThisPhase) {
955
+ systems.runPhase(phase, dt);
956
+ for (let i = 0; i < n; i++) {
957
+ const world = roots[i]!;
958
+ if (world.mounted.drivesOwnLoop) continue;
959
+ // D10/T7.6: a `pausable` world under an active (non-ignored) pause
960
+ // skips every phase except `render` its render still runs, every
961
+ // substep, but with `dt` forced to `0` (deterministic: no
962
+ // time-based render effect silently keeps animating a "frozen"
963
+ // scene). A `pausable: false` world (or any world while
964
+ // `ignorePause`) is unaffected.
965
+ const frozen = !ignorePause && paused && world.pausable;
966
+ if (frozen && phase !== SystemPhase.RENDER) continue;
967
+ const phaseDt = frozen ? 0 : dt;
968
+ // Checklist item 2: isolate each world's per-phase work — one
969
+ // world's `runPhase` throwing must not starve sibling roots still
970
+ // due this phase, nor abort the frame. Mirrors `runOne`'s style in
971
+ // `core/system-runner.ts` (loud console.error, never swallowed).
972
+ try {
973
+ world.frame?.runPhase(phase, phaseDt);
974
+ } catch (err) {
975
+ console.error(
976
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw:`,
977
+ err,
978
+ );
979
+ }
814
980
  }
815
981
  }
982
+ profiler.endPhase(phase);
816
983
  }
817
984
  for (let i = 0; i < n; i++) {
818
- const world = worlds[i]!;
985
+ const world = roots[i]!;
819
986
  if (world.mounted.drivesOwnLoop) continue;
820
987
  // A fully-frozen world gets no `endFrame`/opaque-`update` call either —
821
988
  // there is nothing to "end the frame" of when nothing ran this substep.
@@ -850,18 +1017,25 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
850
1017
  // once per completed `runFrame`/`step()` call, never per phase/world —
851
1018
  // and, per §7.1-11's fix, only when `advanced` (see above) is true: a
852
1019
  // fully-gated paused game produces no notifications at all.
853
- if (advanced) stateBridge.bump();
1020
+ if (advanced) {
1021
+ stateBridge.bump();
1022
+ tick++;
1023
+ simT += dt;
1024
+ }
1025
+ profiler.endFrame();
1026
+ if (rngTrapEnabled) rngTrap.disable();
854
1027
  }
855
1028
 
856
- return {
1029
+ const gameInternal: GameInternal = {
857
1030
  loop: opts.loop,
858
1031
  assets: opts.assets,
1032
+ profiler,
859
1033
  systems,
860
- get worlds() {
861
- return worlds;
1034
+ get roots() {
1035
+ return roots;
862
1036
  },
863
1037
  world(id: string): WorldInstance | null {
864
- return worlds.find((w) => w.id === id) ?? null;
1038
+ return roots.find((w) => w.id === id) ?? null;
865
1039
  },
866
1040
  get defaultWorld() {
867
1041
  return requireDefaultWorld();
@@ -894,7 +1068,7 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
894
1068
  setWorldGates(false);
895
1069
  },
896
1070
  step(dt = 1 / 60) {
897
- // Self-driven pausable worlds advance via their adapter's `step()`
1071
+ // Self-driven pausable roots advance via their adapter's `step()`
898
1072
  // capability — but only the ones that are actually FROZEN: the game
899
1073
  // must be paused, and the world must have been gate-able in the
900
1074
  // first place (`setPaused` present — a world the gate couldn't reach
@@ -902,7 +1076,7 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
902
1076
  // loop, the same §7.1-2 class as the host-driven fix below). While
903
1077
  // not paused, nothing here runs — step() is a whole-call no-op.
904
1078
  if (paused) {
905
- for (const world of worlds) {
1079
+ for (const world of roots) {
906
1080
  if (!world.pausable || !world.mounted.drivesOwnLoop) continue;
907
1081
  if (!world.mounted.setPaused) continue; // never gated — still running
908
1082
  if (world.mounted.step) {
@@ -924,9 +1098,9 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
924
1098
  runFrameImpl(dt, { onlyFrozen: true });
925
1099
  },
926
1100
  },
927
- queryByComponent<T extends GameComponent<WorldKind>>(
1101
+ queryByComponent<T extends GameComponent<AdapterSurface>>(
928
1102
  cls: new () => T,
929
- opts?: { worldId?: string; kind?: WorldKind },
1103
+ opts?: { worldId?: string; kind?: AdapterSurface },
930
1104
  ): T[] {
931
1105
  // Checklist item 8a: a `worldId` naming a world that doesn't exist is a
932
1106
  // caller error (typo'd id, wrong manifest) — degrade loudly, matching
@@ -934,11 +1108,11 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
934
1108
  // exist but isn't first-party legitimately answers "no components"
935
1109
  // below (the loop's `!isFirstPartyMounted` `continue`), which stays
936
1110
  // silent — that's a real, not a mistaken, empty result.
937
- if (opts?.worldId !== undefined && !worlds.some((w) => w.id === opts.worldId)) {
1111
+ if (opts?.worldId !== undefined && !roots.some((w) => w.id === opts.worldId)) {
938
1112
  throw new Error(`Game.queryByComponent: unknown worldId "${opts.worldId}"`);
939
1113
  }
940
1114
  const result: T[] = [];
941
- for (const world of worlds) {
1115
+ for (const world of roots) {
942
1116
  if (!isFirstPartyMounted(world.mounted)) continue;
943
1117
  if (opts?.worldId !== undefined && world.id !== opts.worldId) continue;
944
1118
  if (opts?.kind !== undefined && world.kind !== opts.kind) continue;
@@ -951,7 +1125,7 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
951
1125
  return result;
952
1126
  },
953
1127
  registerWorld(world: WorldInstance): void {
954
- if (worlds.some((w) => w.id === world.id)) {
1128
+ if (roots.some((w) => w.id === world.id)) {
955
1129
  throw new Error(`Game.registerWorld: duplicate world id "${world.id}"`);
956
1130
  }
957
1131
  // Checklist item 6: the SAME `mounted` object registered under two
@@ -962,14 +1136,47 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
962
1136
  // `originalDispose`, which is harmless today only by accident of
963
1137
  // `VgaiSceneGameAdapter.dispose` being idempotent; a foreign adapter
964
1138
  // has no such guarantee). Reject it outright instead.
965
- if (worlds.some((w) => w.mounted === world.mounted)) {
1139
+ if (roots.some((w) => w.mounted === world.mounted)) {
966
1140
  throw new Error(
967
1141
  `Game.registerWorld: world "${world.id}" shares its \`mounted\` object with an ` +
968
- `already-registered world ("${worlds.find((w) => w.mounted === world.mounted)!.id}") — ` +
1142
+ `already-registered world ("${roots.find((w) => w.mounted === world.mounted)!.id}") — ` +
969
1143
  'the same mount cannot be registered twice.',
970
1144
  );
971
1145
  }
972
- worlds.push(world);
1146
+ roots.push(world);
1147
+ // Notify state-bridge subscribers THE INSTANT the world list changes —
1148
+ // not just at the next completed `runFrame`. Root-cause fix for the
1149
+ // "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI
1150
+ // (docs/R3F-FIRST-PARTY-DESIGN.md W3, gate 4): `mountAllWorldSpecs`
1151
+ // (`create-runtime.ts`) mounts roots SEQUENTIALLY, and a react world's
1152
+ // `adapter.mount()` (`resolveDefaultReactAdapter`/`mountOneReactWorld`)
1153
+ // renders its tree — synchronously in some React builds, but React 19's
1154
+ // concurrent renderer does NOT guarantee a synchronous first commit
1155
+ // (see `r3f-adapter.tsx`'s own doc comment on `onCreated`) — BEFORE the
1156
+ // caller calls `registerWorld` for that very world. A `useGameState`
1157
+ // selector reading `g.roots` can therefore render for the first time
1158
+ // while `roots` is still missing entries that register moments later.
1159
+ // Previously the ONLY way such a subscriber ever saw the corrected
1160
+ // value was the state bridge's next `bump()`, which fires exclusively
1161
+ // from `GameInternal.runFrame`'s tail — i.e. only once the host's loop
1162
+ // has actually ticked (`started = true; loop.start()` in
1163
+ // `create-runtime.ts`, itself gated on EVERY world finishing its
1164
+ // mount). On a slow/contended host (CI's 4-core SwiftShader runners)
1165
+ // that first tick can be delayed well past a test's assertion window,
1166
+ // or — if the tab is ever backgrounded — not fire at all for a long
1167
+ // stretch; the subscriber's cached snapshot then sits on its stale
1168
+ // (possibly fully empty) first render for that whole time, matching
1169
+ // the observed CI symptom exactly ("the HUD element IS mounted; the
1170
+ // roots list is EMPTY"). Proven red-then-green by
1171
+ // `packages/engine/test/state-bridge.test.ts`'s "registerWorld notifies
1172
+ // subscribers immediately" case: a subscriber registered before a
1173
+ // world, with `runFrame` NEVER called, only saw the update after this
1174
+ // fix. `bump()` also advances `frameVersion` (the only invalidation
1175
+ // key `createFrameSelectorCache`/`useGameState` understand — see
1176
+ // `frame-selector-cache.ts`), so this doubles as "frameVersion is
1177
+ // bumped once per completed runFrame OR once per world registered",
1178
+ // documented on `GameStateBridge.frameVersion` below.
1179
+ stateBridge.bump();
973
1180
  // T7.4 slice 2 (REACT-STATE-BRIDGE.md §4): a NON-first-party mount
974
1181
  // (an ingested/foreign world — first-party mounts are exempt, they're
975
1182
  // observed via `Game.state`/`useGameState` instead) with no `observe`
@@ -999,5 +1206,47 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
999
1206
  runFrame(dt: number, frameOpts?: { ignorePause?: boolean }): void {
1000
1207
  runFrameImpl(dt, frameOpts);
1001
1208
  },
1209
+ runTicks(n: number, ticksOpts?: RunTicksOptions): void {
1210
+ if (!Number.isInteger(n) || n < 0) {
1211
+ throw new RangeError(`Game.runTicks: n must be a non-negative integer, got ${n}`);
1212
+ }
1213
+ // Refuse while paused (D15 §2.b): stepping the frozen set is
1214
+ // `Game.play.step()`'s contract, not this one's — see `runTicks`'s doc
1215
+ // comment on `GameInternal` above.
1216
+ if (paused) {
1217
+ throw new DebugError(
1218
+ 'RUN_TICKS_PAUSED',
1219
+ 'Game.runTicks: refused — Game.play.paused is true; stepping the frozen set is ' +
1220
+ "Game.play.step()'s contract, not runTicks' (docs/D15-DETERMINISM-DESIGN.md §2.b)",
1221
+ );
1222
+ }
1223
+ const render = ticksOpts?.render ?? 'last';
1224
+ const fixedDt = opts.loop.fixedDt;
1225
+ for (let i = 0; i < n; i++) {
1226
+ const isFinalTick = i === n - 1;
1227
+ const skipRenderPhases = render === 'all' ? false : render === 'none' ? true : !isFinalTick;
1228
+ runFrameImpl(fixedDt, { skipRenderPhases });
1229
+ }
1230
+ },
1002
1231
  };
1232
+
1233
+ // Filed AFTER the shell exists (the WeakMap keys on the Game object
1234
+ // itself) so `getDebugRegistry(game)` — the react hooks' and any later
1235
+ // consumer's reach-in — works from the moment `createGame` returns.
1236
+ registerDebugRegistry(gameInternal, debugRegistry);
1237
+ // D15/T-D15.4: wire the run-ticks target the instant the Game shell exists
1238
+ // (unlike `setVirtualInputTarget`, which waits for a per-world mount, a
1239
+ // Game's own `runTicks` needs nothing else) — this is what makes
1240
+ // `window.__vgai.runTicks` (`debug-bridge.ts`) and the editor relay's
1241
+ // `run-ticks` case reach the SAME implementation `game.runTicks` above is.
1242
+ debugRegistry.setRunTicksTarget({ runTicks: gameInternal.runTicks });
1243
+ // D15 (T-D15.1/.3) — same "file after the shell exists" ordering as the
1244
+ // debug registry above: `getSeededRandom(game)`/`getGameplayRngTrapControl
1245
+ // (game)` (per-world `ctx.random` wiring, and the manifest-aware boot
1246
+ // path's post-hoc `setEnabled` call) both work from the moment
1247
+ // `createGame` returns.
1248
+ registerSeededRandom(gameInternal, seededRandom);
1249
+ registerGameplayRngTrapControl(gameInternal, rngTrapControl);
1250
+
1251
+ return gameInternal;
1003
1252
  }