@volter/editor-game 0.5.66 → 0.5.68

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 (239) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,1636 @@
1
+ /**
2
+ * The surface-neutral Game root and its declaration-ordered root registry.
3
+ * Three, Canvas, and DOM roots share one game-scoped loop, input manager,
4
+ * system runner, state bridge, debug registry, clock, and seeded RNG. Each
5
+ * RootInstance retains its own mounted surface and optional capabilities.
6
+ */
7
+
8
+ import type { AdapterSurface as AdapterSurfaceLeaf } from '@volter/editor-project/adapter/adapter-surface';
9
+ import type { RootBinding } from '@volter/editor-project/adapter/binding';
10
+ import {
11
+ formatAudioGateMessage,
12
+ formatLoopGateMessage,
13
+ } from '@volter/editor-project/adapter/loop-gate-report';
14
+ import type { MountedRoot } from '@volter/editor-project/adapter/root-adapter';
15
+ import type { AudioAdapter, SystemAdapters } from '@volter/editor-project/adapter/system-adapter';
16
+ import { bindScopedSystem } from '@volter/editor-project/adapter/system-slot';
17
+ import type { AssetCache } from '@volter/threejs-runtime/assets';
18
+ import { hasUserData } from '@volter/threejs-runtime/ecs/user-data';
19
+ import type { CollisionSystem } from '@volter/threejs-runtime/physics/collision-system';
20
+ import type { PhysicsRegistry } from '@volter/threejs-runtime/physics/physics-registry';
21
+ import type * as THREE from 'three';
22
+ import type { createGameLoop } from './core/game-loop';
23
+ import {
24
+ createSeededRandom,
25
+ DEFAULT_SEEDED_RANDOM_SEED,
26
+ registerSeededRandom,
27
+ } from './core/seeded-random';
28
+ import { createSimClock, registerSimClock, type SimClockInternal } from './core/sim-clock';
29
+ import { createSystemRunner, type SystemRunner } from './core/system-runner';
30
+ import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from './core/types';
31
+ import { createPerformanceProfiler, type PerformanceProfiler } from './dev/performance-profiler';
32
+ // TYPE-ONLY (same rule as the pixi import above): this lives under `pixi/`,
33
+ // but `game.ts` only ever names its TYPE.
34
+ import type { Physics2DRegistry } from './pixi/physics-registry';
35
+ import {
36
+ createDebugRegistry,
37
+ DebugError,
38
+ type RunTicksOptions,
39
+ registerDebugRegistry,
40
+ } from './debug-registry';
41
+ import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
42
+ import type { PlaytestContext } from './playtest';
43
+
44
+ /** The one loop type — `createGameLoop`'s return shape (fixed-step sim,
45
+ * display-rate presentation). */
46
+ export type GameLoop = ReturnType<typeof createGameLoop>;
47
+
48
+ /**
49
+ * The tail of `PHASE_ORDER` that presents rather than simulates, and
50
+ * therefore runs once per DISPLAY frame (`runRenderFrameImpl`) rather than
51
+ * once per fixed substep. The same two phases `runTicks`' `skipRenderPhases`
52
+ * fast-forward skips and `render-control.ts`'s `renderOnce()` drives — one
53
+ * definition of "the render phases" across all three, kept in `PHASE_ORDER`'s
54
+ * own order.
55
+ */
56
+ const DISPLAY_RATE_PHASES: readonly SystemPhaseName[] = PHASE_ORDER.filter(
57
+ (phase) => phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER,
58
+ );
59
+
60
+ /**
61
+ * Game-level play-state control surface (D10, T7.6): pause/resume/step as ONE
62
+ * control surface on the running game, with PER-WORLD `pausable` semantics
63
+ * (`RootInstance.pausable`) — a menu/HUD world declaring `pausable: false` keeps
64
+ * ticking (input, physics) while every `pausable: true` world
65
+ * freezes. A paused-and-pausable world's `render` phase still runs (with `dt`
66
+ * forced to `0`, so time-based render effects — e.g. a post-processing pass with
67
+ * its own internal clock — don't silently keep animating under a "frozen" scene)
68
+ * — simulation freezes, the screen does not go black. That render runs once per
69
+ * display frame under a host that drives `runRenderFrame`, and once per substep
70
+ * under one that still renders inside
71
+ * `runFrame`. `GameLoop.timeScale` remains the orthogonal
72
+ * "speed up/slow down" axis — pausing never touches it, so the host's
73
+ * accumulator/rAF loop keeps ticking at its normal cadence, which is what makes
74
+ * "paused still renders" possible.
75
+ */
76
+ export interface PlayState {
77
+ /** Whether the game is currently paused (game-level — see the per-world
78
+ * `pausable` caveat above: a `pausable: false` world ignores this). */
79
+ readonly paused: boolean;
80
+ /**
81
+ * Freeze every `pausable` world's simulation (idempotent — a second call
82
+ * while already paused is a no-op) and, for every `pausable`, self-driven
83
+ * (`drivesOwnLoop`) world, invoke its loop-gate capability
84
+ * (`mounted.setPaused(true)`) — reporting loudly, once per world, when that
85
+ * capability is absent (an honest "cannot gate" instead of a silent no-op).
86
+ * Also silences every `pausable` world's audio via its
87
+ * `SystemAdapters.audio` (absent ⇒ the same loud, once-only report).
88
+ */
89
+ pause(): void;
90
+ /** Resume every `pausable` world — the inverse of `pause()`, same
91
+ * idempotency and loop-gate/audio fan-out (no re-reporting; a world that
92
+ * couldn't be gated on pause simply never was, so resume has nothing to
93
+ * undo for it). */
94
+ resume(): void;
95
+ /**
96
+ * Advance exactly the currently-FROZEN roots by one fixed substep: the
97
+ * `pausable`, host-driven (`!drivesOwnLoop`) roots while `paused` is
98
+ * true. While NOT paused this is a whole-call no-op — under D10 the loop
99
+ * never stops, so every non-frozen world is already being ticked; an
100
+ * unconditional extra tick was the §7.1-2 double-tick bug (probe4). A
101
+ * `pausable`, self-driven world that was actually gated by `pause()`
102
+ * (adapter has `setPaused`) steps via its own loop-gate `step()`
103
+ * capability instead (absent ⇒ the same loud report as `pause()`); a
104
+ * self-driven world the gate couldn't reach is still running and is left
105
+ * alone. `dt` defaults to the engine's fixed timestep (1/60s — every real
106
+ * host constructs its loop with this value; see `create-runtime.ts`).
107
+ */
108
+ step(dt?: number): void;
109
+ }
110
+
111
+ /**
112
+ * The kinds of render surface a world can be. `'three'` and `'canvas'`
113
+ * roots are canvas-backed; `'dom'` roots own a DOM layer.
114
+ *
115
+ * Re-exported from `adapter/adapter-surface.ts` so
116
+ * `adapter/root-adapter.ts`'s kind-tagged `MountedRoot` types can name it
117
+ * without an import cycle back to this file. This re-export keeps every
118
+ * existing `import type { AdapterSurface } from './game'` call site
119
+ * compiling unchanged.
120
+ */
121
+ export type AdapterSurface = AdapterSurfaceLeaf;
122
+
123
+ /**
124
+ * A world's per-phase frame hooks.
125
+ * Populated on a `RootInstance` only for first-party mounts — an opaque/
126
+ * foreign mount has no phase-partitioned entry point, so it stays
127
+ * `undefined` and the Game's frame executor falls back to its single
128
+ * `mounted.update(dt)`. The mount's `updateCadence` chooses fixed-substep
129
+ * (the default) or display-rate dispatch.
130
+ */
131
+ export interface RootFrameHooks {
132
+ /** Run this world's engine systems + component ticks + world-bound game
133
+ * systems for one phase. For a first-party world this delegates to the
134
+ * same `SystemRunner.runPhase` as its direct `mounted.update` entry. */
135
+ runPhase(phase: SystemPhaseName, dt: number): void;
136
+ /** Run once per substep, after ALL phases have run for ALL roots this
137
+ * substep (mirrors where `mounted.update`'s post-`systems.run` work sat
138
+ * today — e.g. `input.endFrame()`). Optional: a world may have nothing
139
+ * to do here. */
140
+ endFrame?(): void;
141
+ }
142
+
143
+ /**
144
+ * A single world instance: the unit of adaptation.
145
+ */
146
+ /**
147
+ * One `SystemAdapters` slot a mounted root's `systems` table answered with
148
+ * `absent(reason)` (`adapter/system-slot.ts`) — a POSITIVE absence.
149
+ *
150
+ * Nothing is installed for it. It exists so a reader can tell "this game has no
151
+ * physics" from "nobody looked", which the adapter bag alone cannot do: an
152
+ * unfilled slot has the same shape for both, and only one of them is a work
153
+ * order.
154
+ */
155
+ export interface DeclaredSystemAbsence {
156
+ /** The root whose declaration answered — absences are declared per root, and
157
+ * the slot is only truly absent when NO root fills it. */
158
+ readonly rootId: string;
159
+ readonly slot: keyof SystemAdapters;
160
+ /** The game's own words: what was searched, and what was found. */
161
+ readonly reason: string;
162
+ }
163
+
164
+ export interface RootInstance {
165
+ /** Manifest root id. */
166
+ readonly id: string;
167
+ readonly kind: AdapterSurface;
168
+ /** Per-world play/pause semantics. */
169
+ readonly pausable: boolean;
170
+ /** The RootAdapter that produced `mounted` — first-party or external.
171
+ * Deliberately narrower than `RootAdapter<K>` (T7.5): this field is only
172
+ * ever read for `.id` (`Game.registerRoot`'s diagnostic message below) —
173
+ * never re-invoked — and `RootAdapter<K>`'s `mount` signature legitimately
174
+ * differs per kind's host CONTEXT (`HostContextFor<K>`, P-8: a canvas
175
+ * adapter's `CanvasHostContext`, a react adapter's `DomHostContext`, vs a
176
+ * three adapter's `ThreeHostContext`), so requiring the FULL interface here
177
+ * would force each registration site to name its own `K` for no behavioral
178
+ * gain — see `AdapterHandle` below. */
179
+ readonly adapter: AdapterHandle;
180
+ /** The live mounted surface — one of `MountedRoot`'s kind-tagged shapes
181
+ * (T7.5; `MountedThreeRoot` for a three world, `MountedCanvasRoot` for
182
+ * canvas, `MountedReactRoot` for react). */
183
+ readonly mounted: MountedRoot;
184
+ /** Kind-narrowed accessor: throws a descriptive error when this world is
185
+ * not a three world. */
186
+ threeScene(): THREE.Scene;
187
+ /** Kind-narrowed accessor for canvas roots: returns the native substrate
188
+ * root the adapter mounted. The host keeps it opaque; a substrate adapter
189
+ * that declared the matching name narrows `T`. */
190
+ canvasRoot<T = unknown>(): T;
191
+ /** Kind-narrowed accessor for DOM roots: returns the
192
+ * DOM-root layer `<div>` the host mounted this world's react tree into
193
+ * (the SAME element passed as `container` to `createRootInstance` —
194
+ * identity matters, mirroring `threeScene()`/`canvasRoot()`'s "same
195
+ * instance the adapter mounted" contract). Throws descriptively for a
196
+ * non-react world, or a react world built without a `container` (see
197
+ * `RootInstanceInit.container`). */
198
+ reactRoot(): HTMLElement;
199
+ /** Present only when the world's mount is first-party (Rapier3D for
200
+ * three roots). */
201
+ readonly physics?: PhysicsRegistry | undefined;
202
+ readonly collisions?: CollisionSystem | undefined;
203
+ /** Present only when the world's mount owns a Rapier-2D world — the 2D
204
+ * analog of `physics` above. Kept as a separate field (not folded into
205
+ * `physics` as a union) so 3D call sites keep their non-union
206
+ * `PhysicsRegistry` typing unchanged. */
207
+ readonly physics2d?: Physics2DRegistry | undefined;
208
+ /** Kind-typed via `mounted` in T7.5; `unknown` here deliberately. */
209
+ readonly camera?: unknown;
210
+ /** Phase-partitioned frame entry point — present only for
211
+ * first-party mounts. `undefined` for an opaque/foreign mount, which
212
+ * `GameInternal.runFrame` drives via its single `mounted.update` call
213
+ * instead (unless it `drivesOwnLoop`, in which case it isn't ticked at
214
+ * all — see `runFrame`). */
215
+ readonly frame?: RootFrameHooks | undefined;
216
+ /** True from the moment this world's `mounted.dispose()` runs. There is no
217
+ * `unregisterRoot`, so a disposed world stays in `Game.roots`; `runFrame`
218
+ * reads this to skip it entirely — frame hooks AND the opaque
219
+ * `mounted.update` fallback (see the disposed-world guard in
220
+ * `createRootInstance`). */
221
+ readonly disposed: boolean;
222
+ /**
223
+ * THIS ROOT'S WHOLE DECLARATION, BOUND — `adapter/binding.ts`'s
224
+ * {@link RootBinding}, assembled at registration from this instance's own
225
+ * `adapter`/`mounted`/`pausable` plus the {@link RootDeclaration} the
226
+ * resolver produced. Every member of it is a reference to something already
227
+ * reachable through the fields above; it is the ONE place the five protocol
228
+ * families are addressed by name rather than re-assembled per consumer.
229
+ *
230
+ * `null` in exactly ONE honest case, and never as a degrade: the
231
+ * registration carried NO declaration — a bare
232
+ * `registerThreeRoot(game, adapter, mounted)` from a test harness, or a
233
+ * `mountManifestRoots` caller that supplied an already-constructed adapter
234
+ * with no entry namespace behind it. There is no manifest root, no parsed
235
+ * definition and no entry surface to bind, and fabricating them would be
236
+ * the anti-shim rule broken at the seam that exists to hold it. Every root
237
+ * the editor resolves through `resolveAllRoots`/`resolveAllRootEntries`
238
+ * carries a declaration, so its binding is non-null.
239
+ *
240
+ * A mount that exposes no `authoring` does NOT null this out; it costs that
241
+ * root its `projection` and `truth` members only (see
242
+ * {@link RootBinding.projection}). That distinction is load-bearing: the
243
+ * editor supplies native TSX roots' authoring itself, so treating an
244
+ * absent `mounted.authoring` as an absent BINDING made `binding` null for
245
+ * every first-party root and left four perfectly constructible families
246
+ * unreadable.
247
+ */
248
+ readonly binding: RootBinding | null;
249
+ }
250
+
251
+ /**
252
+ * Minimal identity surface `RootInstance.adapter` needs (T7.5) — see that
253
+ * field's doc comment for why it's narrower than `RootAdapter<K>`. Any real
254
+ * adapter object (a NAMED type, not a fresh object literal) — `RootAdapter<K>`,
255
+ * `Pixi2DRootAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
256
+ * satisfies this trivially (extra members beyond `id` are always fine for a
257
+ * non-literal source); a bare `{ id, mount }` object literal built INLINE at
258
+ * a `createRootInstance`/`registerXRoot` call site needs an intermediate
259
+ * `const` (excess-property checking only special-cases fresh literals).
260
+ */
261
+ export interface AdapterHandle {
262
+ readonly id: string;
263
+ }
264
+
265
+ /** Inputs to {@link createRootInstance}. */
266
+ export interface RootInstanceInit {
267
+ readonly id: string;
268
+ readonly kind: AdapterSurface;
269
+ /** Defaults to `true` (D10's per-world default). */
270
+ readonly pausable?: boolean;
271
+ readonly adapter: AdapterHandle;
272
+ readonly mounted: MountedRoot;
273
+ /** Required when `kind === 'three'` — backs `threeScene()`. */
274
+ readonly scene?: THREE.Scene | undefined;
275
+ /** Required when `kind === 'canvas'` — backs `canvasRoot()`, symmetric with
276
+ * `scene` for three above. */
277
+ readonly canvasRoot?: unknown;
278
+ /** Required when `kind === 'dom'` — backs `reactRoot()` (T6.2 slice 1,
279
+ * symmetric with `scene`/`stage` above): the DOM-root layer `<div>` this
280
+ * world's react tree is mounted into. */
281
+ readonly container?: HTMLElement | undefined;
282
+ readonly physics?: PhysicsRegistry | undefined;
283
+ readonly collisions?: CollisionSystem | undefined;
284
+ readonly physics2d?: Physics2DRegistry | undefined;
285
+ readonly camera?: unknown;
286
+ readonly frame?: RootFrameHooks | undefined;
287
+ /** This root's bound declaration — see {@link RootInstance.binding} for
288
+ * when it is legitimately absent. */
289
+ readonly binding?: RootBinding | null | undefined;
290
+ }
291
+
292
+ /**
293
+ * Build a `RootInstance` whose kind-narrowed accessors throw descriptively
294
+ * on kind mismatch. This is the one place that builds
295
+ * `threeScene`/`canvasRoot`, so every world (however it's constructed, in this
296
+ * slice or later ones) gets identical throw behavior.
297
+ */
298
+ export function createRootInstance(init: RootInstanceInit): RootInstance {
299
+ const {
300
+ id,
301
+ kind,
302
+ pausable = true,
303
+ adapter,
304
+ mounted,
305
+ scene,
306
+ canvasRoot,
307
+ container,
308
+ physics,
309
+ collisions,
310
+ physics2d,
311
+ camera,
312
+ frame,
313
+ binding = null,
314
+ } = init;
315
+
316
+ // A three world with no scene has nothing for `threeScene()` to return —
317
+ // fail loudly HERE, at construction, rather than letting `threeScene()`
318
+ // throw its generic "not a three world" message later for a world whose
319
+ // kind IS three (checklist item 5; a misleading error for this case).
320
+ if (kind === 'three' && !scene) {
321
+ throw new Error(
322
+ `RootInstance "${id}" (kind: three): a three world requires a scene — ` +
323
+ 'pass `scene` in RootInstanceInit.',
324
+ );
325
+ }
326
+
327
+ // Symmetric check for canvas — a canvas world with no native substrate root
328
+ // has nothing for `canvasRoot()` to return; fail loudly here, at
329
+ // construction, same as the three/scene check above.
330
+ if (kind === 'canvas' && (canvasRoot === undefined || canvasRoot === null)) {
331
+ throw new Error(
332
+ `RootInstance "${id}" (kind: canvas): a canvas world requires a native substrate root — ` +
333
+ 'pass `canvasRoot` in RootInstanceInit.',
334
+ );
335
+ }
336
+
337
+ // Symmetric check for react (T6.2 slice 1) — a react world with no
338
+ // container has nothing for `reactRoot()` to return; fail loudly here,
339
+ // at construction, same as the two checks above.
340
+ if (kind === 'dom' && !container) {
341
+ throw new Error(
342
+ `RootInstance "${id}" (kind: react): a react world requires a container — ` +
343
+ 'pass `container` in RootInstanceInit.',
344
+ );
345
+ }
346
+
347
+ // Disposed-world guard (T7.1 slice 3). There
348
+ // is no `unregisterRoot` (decision 4 — roots are manifest-declared; a
349
+ // whole Game is disposed, not one world out of its registry), so a caller
350
+ // that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
351
+ // leaves that `RootInstance` sitting in `Game.roots` — and `runFrame`
352
+ // would otherwise keep invoking its (now-torn-down) frame hooks every
353
+ // subsequent frame. `MountedThreeRoot` has no public "am I disposed" flag to
354
+ // read, so this wraps `mounted.dispose` in place (mutating the SAME mount
355
+ // object every holder of `mounted` shares — calling `mounted.dispose()`
356
+ // directly, exactly like calling `world.mounted.dispose()`, trips this)
357
+ // to flip a flag this instance exposes as `disposed`, and wraps the frame
358
+ // hooks so they silently no-op once it is set. `runFrame` reads `disposed`
359
+ // and skips the world outright, which is what also covers the OPAQUE
360
+ // `mounted.update` fallback — the path every three root takes now that a
361
+ // mount is a plain `RootAdapter` with no phase-partitioned entry point.
362
+ let disposed = false;
363
+ const originalDispose = mounted.dispose.bind(mounted);
364
+ mounted.dispose = () => {
365
+ disposed = true;
366
+ originalDispose();
367
+ };
368
+ const guardedFrame: RootFrameHooks | undefined = frame
369
+ ? {
370
+ runPhase(phase, dt) {
371
+ if (disposed) return;
372
+ frame.runPhase(phase, dt);
373
+ },
374
+ endFrame() {
375
+ if (disposed) return;
376
+ frame.endFrame?.();
377
+ },
378
+ }
379
+ : undefined;
380
+
381
+ return {
382
+ id,
383
+ kind,
384
+ pausable,
385
+ adapter,
386
+ mounted,
387
+ physics,
388
+ collisions,
389
+ physics2d,
390
+ camera,
391
+ frame: guardedFrame,
392
+ binding,
393
+ get disposed(): boolean {
394
+ return disposed;
395
+ },
396
+ threeScene(): THREE.Scene {
397
+ if (kind !== 'three' || !scene) {
398
+ throw new Error(
399
+ `RootInstance "${id}" (kind: ${kind}): threeScene() requested but this world is not ` +
400
+ 'a three world',
401
+ );
402
+ }
403
+ return scene;
404
+ },
405
+ canvasRoot<T = unknown>(): T {
406
+ if (kind !== 'canvas' || canvasRoot === undefined || canvasRoot === null) {
407
+ throw new Error(
408
+ `RootInstance "${id}" (kind: ${kind}): canvasRoot() requested but this world is not ` +
409
+ 'a canvas world',
410
+ );
411
+ }
412
+ return canvasRoot as T;
413
+ },
414
+ reactRoot(): HTMLElement {
415
+ // The `!container` branch a react world could hit here is now
416
+ // unreachable (T6.2 slice 1): construction above throws for
417
+ // `kind === 'dom'` with no `container`, symmetric with
418
+ // `threeScene()`/`canvasRoot()`'s guards.
419
+ if (kind !== 'dom' || !container) {
420
+ throw new Error(
421
+ `RootInstance "${id}" (kind: ${kind}): reactRoot() requested but this world is not ` +
422
+ 'a react world',
423
+ );
424
+ }
425
+ return container;
426
+ },
427
+ };
428
+ }
429
+
430
+ /**
431
+ * One world's half of a debris disposal, as {@link disposeDebrisSubtree} needs
432
+ * it.
433
+ *
434
+ * `physics`/`rapierWorld` are OPTIONAL: an owner that builds no first-party
435
+ * Rapier runtime (an R3F world) carries neither, and {@link
436
+ * disposeDebrisSubtree} reads them behind `readRapierHandles` rather than
437
+ * unconditionally.
438
+ */
439
+ export interface DebrisOwner {
440
+ readonly physics?: PhysicsRegistry;
441
+ readonly rapierWorld?: import('@dimforge/rapier3d-compat').World;
442
+ }
443
+
444
+ /**
445
+ * Read an owner's Rapier handles, treating an absent pair as "this owner holds
446
+ * no rigid bodies" — the honest reading for a world (R3F, or a bare
447
+ * `ComponentManager`-only owner) that builds no first-party physics runtime.
448
+ */
449
+ function readRapierHandles(owner: DebrisOwner): {
450
+ physics: NonNullable<DebrisOwner['physics']>;
451
+ rapierWorld: NonNullable<DebrisOwner['rapierWorld']>;
452
+ } | null {
453
+ const { physics, rapierWorld } = owner;
454
+ if (!physics || !rapierWorld) return null;
455
+ return { physics, rapierWorld };
456
+ }
457
+
458
+ /**
459
+ * P3 — what `SimClock.disposeAfter` actually does. `core/sim-clock.ts` knows
460
+ * only *when*; this is the *what*, and it lives here because it is the runtime
461
+ * that knows about Rapier and shared geometry.
462
+ *
463
+ * The order mirrors `editor-game/src/host/roots/r3f-root.tsx`'s world teardown for
464
+ * ONE subtree. Within the physics step,
465
+ * `rapierWorld.removeRigidBody(body)` comes BEFORE `physics.remove(node)`: the
466
+ * registry's `remove()` only drops index entries, so reversing the two leaks
467
+ * the Rapier body.
468
+ *
469
+ * `owners` is every world that could own part of the subtree. `physics.get`
470
+ * is a no-op for a node the
471
+ * owner does not own, so offering the subtree to each is safe.
472
+ *
473
+ * Safe on an object already removed or already disposed: `removeFromParent` on
474
+ * a parentless `Object3D` is a no-op and three's `dispose()` calls are
475
+ * idempotent.
476
+ */
477
+ export function disposeDebrisSubtree(
478
+ obj: THREE.Object3D,
479
+ owners: ReadonlyArray<DebrisOwner>,
480
+ ): void {
481
+ const nodes: THREE.Object3D[] = [];
482
+ obj.traverse((node) => nodes.push(node));
483
+ for (const owner of owners) {
484
+ const rapier = readRapierHandles(owner);
485
+ if (!rapier) continue;
486
+ for (const node of nodes) {
487
+ const refs = rapier.physics.get(node);
488
+ if (!refs) continue;
489
+ rapier.rapierWorld.removeRigidBody(refs.body);
490
+ rapier.physics.remove(node);
491
+ }
492
+ }
493
+ obj.removeFromParent();
494
+ for (const node of nodes) {
495
+ // Duck-typed rather than `instanceof THREE.Mesh` so this module keeps its
496
+ // TYPE-ONLY three import (it is surface-neutral — it also hosts pixi
497
+ // roots). Covers Points/Line/Sprite debris too, which a Mesh check would
498
+ // silently leak.
499
+ const drawable = node as Partial<THREE.Mesh>;
500
+ if (!hasUserData(node, '__sharedGeometry')) drawable.geometry?.dispose();
501
+ const material = drawable.material;
502
+ if (Array.isArray(material)) {
503
+ for (const m of material) m.dispose();
504
+ } else {
505
+ material?.dispose();
506
+ }
507
+ }
508
+ }
509
+
510
+ /**
511
+ * The {@link DebrisOwner}s one registered world contributes. Written as a
512
+ * standalone function
513
+ * so `createGame`'s clock disposer is one line and the "which mounts count"
514
+ * rule has exactly one home.
515
+ */
516
+ function debrisOwnersOf(_world: RootInstance): DebrisOwner[] {
517
+ // No mount carries first-party Rapier handles: physics is a declared
518
+ // system built inside the world's own tree, and the library owns its
519
+ // bodies' lifecycles there. The owner list stays as the disposal seam's
520
+ // shape; today it is always empty.
521
+ return [];
522
+ }
523
+
524
+ /**
525
+ * The Game root. Owns the loop, raw-asset cache, world registry, input,
526
+ * and game-scoped `SystemRunner`. Surface-specific capabilities remain on
527
+ * their mounted roots or in the aggregated `SystemAdapters` contract.
528
+ */
529
+ export interface Game {
530
+ readonly loop: GameLoop;
531
+ readonly assets: AssetCache;
532
+ /** Host identity for this private play run or coordinated Team Test. */
533
+ readonly playtest?: PlaytestContext | null;
534
+ /** Per-game diagnostic store. Disabled by default; the editor enables it on demand. */
535
+ readonly profiler: PerformanceProfiler;
536
+ /**
537
+ * The game-scoped `SystemRunner`, separate from any world's own runner.
538
+ * Within each phase,
539
+ * `GameInternal.runFrame` runs THIS runner's `runPhase` first, before any
540
+ * world's engine systems/component ticks/world-bound game systems (e.g.
541
+ * `ctx.systems.add`, which stays world-bound to the default world).
542
+ * Empty for every existing game (nothing registers
543
+ * against it), so an empty runner has no frame cost beyond dispatch.
544
+ */
545
+ readonly systems: SystemRunner;
546
+ /** Declaration-ordered. This is the same array reference `registerRoot`
547
+ * mutates, not a snapshot, so holders observe
548
+ * later registrations. */
549
+ readonly roots: ReadonlyArray<RootInstance>;
550
+ world(id: string): RootInstance | null;
551
+ /** First three world, else first world. Throws descriptively when no
552
+ * world has been registered yet. */
553
+ readonly defaultRoot: RootInstance;
554
+ /**
555
+ * Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3:
556
+ * "`registerSystemAdapter` is game-scoped" — the recorded decision this
557
+ * getter finally implements; probe1). Each mounted world builds its OWN
558
+ * `mounted.systems` object (a game registers capabilities like `networking`
559
+ * from ITS OWN `setup()`, via `ctx.registerSystemAdapter`, per-world) —
560
+ * this merges every world's `mounted.systems` into ONE `SystemAdapters`, in
561
+ * `roots` REGISTRATION order, first registration wins per key. A later
562
+ * world registering the SAME kind (e.g. two roots both exposing
563
+ * `networking`) does not override the first — instead this warns ONCE per
564
+ * (game instance, key) naming both world ids, matching this file's `[game]
565
+ * world "<id>" …` console idiom (see `registerRoot` below). NOT to be
566
+ * confused with `Game.systems` (the game-scoped `SystemRunner` bucket, an
567
+ * entirely different concept — see that field's doc comment) — this name
568
+ * was deliberately chosen not to collide with it.
569
+ *
570
+ * A plain getter (recomputed on every read, not cached) so a LATER
571
+ * `registerRoot` call (or a world's setup registering a NEW adapter kind
572
+ * after this was first read) is always reflected — only the COLLISION
573
+ * warning is deduped (once per key, for the lifetime of this `Game`).
574
+ * Includes every world regardless of first-party-ness — `mounted.systems`
575
+ * is a capability any adapter (first-party or foreign) may expose.
576
+ */
577
+ readonly systemAdapters: SystemAdapters;
578
+ /** Subscribe when a mounted root registers or replaces a system adapter. */
579
+ subscribeSystemAdapters?(listener: () => void): () => void;
580
+ /**
581
+ * Install a root's STATICALLY DECLARED system-adapter slots — the native
582
+ * module surface's `export const systems` (adapter/native-debug-module.ts),
583
+ * already validated by `contract-system-adapters.ts`. The host calls this
584
+ * once per declaring root at mount; project components never do
585
+ * (ARCHITECTURE-CORE §System adapters: "Components never call
586
+ * `registerSystemAdapter`" — this door is the replacement, and the slots it
587
+ * installs take precedence over a component registration for the same root
588
+ * in {@link systemAdapters}'s merge, with the standard collision warning).
589
+ * Throws on an unmounted root id or a second declaration for the same root.
590
+ * Optional for the same reason {@link subscribeSystemAdapters} is: partial
591
+ * `Game` doubles in tests; `installNativeSystemsBindings` refuses loudly
592
+ * when the hosting Game lacks it.
593
+ */
594
+ installDeclaredSystemAdapters?(
595
+ rootId: string,
596
+ slots: Readonly<Partial<SystemAdapters>>,
597
+ absent?: readonly DeclaredSystemAbsence[],
598
+ ): void;
599
+ /**
600
+ * Every slot a mounted root's `systems` table answered with `absent(reason)`,
601
+ * across all roots — the game-scoped read of the positive absences.
602
+ *
603
+ * This is what separates "this game has no networking" from "nobody looked",
604
+ * and it is the ONLY source for that distinction on a native mount: the
605
+ * adapter bag ({@link systemAdapters}) can only say a slot is unfilled, which
606
+ * is the same shape for both. Empty means no root declared any absence — NOT
607
+ * that every slot is answered.
608
+ */
609
+ readonly declaredSystemAbsences?: readonly DeclaredSystemAbsence[];
610
+ /** Game-level play-state control surface (D10, T7.6) — see {@link PlayState}. */
611
+ readonly play: PlayState;
612
+ /**
613
+ * The interpolation alpha the most recent display frame presented
614
+ * at: `accumulator / fixedDt`, in `[0, 1]`. `0` means "exactly on the last
615
+ * completed fixed state", `0.5` means "halfway to the next one".
616
+ *
617
+ * Read it to interpolate your own transforms between the last two fixed
618
+ * states; it is the same number `onRenderStep` hands its callbacks, exposed
619
+ * here for code that renders from somewhere other than a callback. Stays
620
+ * `0` for a Game whose host never wired a display-rate render pass (see
621
+ * {@link onRenderStep}).
622
+ */
623
+ readonly renderAlpha: number;
624
+ /**
625
+ * The `RenderStepped`-shaped host: register a callback that runs
626
+ * ONCE PER DISPLAY FRAME, before that frame's `preRender`/`render` phases,
627
+ * with `(alpha, displayDt)`.
628
+ *
629
+ * This is the seam for presentation-only work whose natural rate is the
630
+ * monitor's, not the simulation's — camera polish/smoothing, procedural
631
+ * sway, a cosmetic bob. It is NOT a gameplay hook: it can fire twice
632
+ * between two fixed substeps (120 Hz display, 60 Hz sim) and zero times
633
+ * across a `runTicks` fast-forward, so anything that must be deterministic
634
+ * belongs in a fixed engine phase (`ctx.systems.add`) instead. Ecosystem
635
+ * presentation hooks such as R3F's `useFrame` share this display clock.
636
+ *
637
+ * Reached from game code as `ctx.game?.onRenderStep(...)`. Returns an
638
+ * unsubscribe function; pass `opts.signal` to unsubscribe with the same
639
+ * `AbortSignal` a `setup()` already uses for its listeners and sim timers
640
+ * (`core/sim-clock.ts`'s ownership section — cancelling what you registered
641
+ * is the game's job, and a stale render-step closure is the same hazard
642
+ * class as a stale event listener).
643
+ *
644
+ * Silent-no-op honesty: a callback registered on a Game whose host wired no
645
+ * display-rate render pass (a bare test harness, a `drivesOwnLoop`-only
646
+ * mount, an `externalDrive` capture page) never fires, because nothing
647
+ * drives it. That is the same shape as `ctx.clock` on a Game-less mount.
648
+ */
649
+ onRenderStep(
650
+ fn: (alpha: number, displayDt: number) => void,
651
+ opts?: { signal?: AbortSignal | undefined },
652
+ ): () => void;
653
+ }
654
+
655
+ /**
656
+ * Host-internal extension of {@link Game}: adds `registerRoot` (the host's
657
+ * wiring surface for populating the world registry) and `runFrame` (the
658
+ * frame executor). NEITHER is part of the game-facing `Game` surface —
659
+ * games never call either directly; the host loop
660
+ * (`createGameRuntime`) and `GameSession.step()` are the only callers of
661
+ * `runFrame`.
662
+ */
663
+ export interface GameInternal extends Game {
664
+ /** Host-side signal used by root contexts after `registerSystemAdapter`. */
665
+ notifySystemAdaptersChanged(): void;
666
+ /** Append a world in declaration order. Throws on a duplicate id. */
667
+ registerRoot(world: RootInstance): void;
668
+ /**
669
+ * Run ONE fixed substep across every phase and every world:
670
+ *
671
+ * ```
672
+ * for phase in PHASE_ORDER:
673
+ * game.systems.runPhase(phase, dt) // game-scoped, first
674
+ * for world in roots (declaration order):
675
+ * if world.mounted.drivesOwnLoop: continue
676
+ * world.frame?.runPhase(phase, dt)
677
+ * for world in roots: // after ALL phases
678
+ * if world.mounted.drivesOwnLoop: continue
679
+ * if world.frame: world.frame.endFrame?.()
680
+ * else if updateCadence != 'display' OR render phases are included:
681
+ * world.mounted.update?.(dt) // opaque world fallback
682
+ * ```
683
+ *
684
+ * A single first-party world and its direct `mounted.update(dt)` entry run
685
+ * the same `SystemRunner` and `postFrame` work. A `drivesOwnLoop` world is
686
+ * never ticked here. An opaque host-driven world (no `frame`) gets exactly
687
+ * one `update(dt)` call per substep after the phase loop by default. A
688
+ * display-cadence opaque world is withheld when `skipRenderPhases` is true;
689
+ * the matching host calls it once from {@link runRenderFrame}. A direct
690
+ * `runFrame` with render phases included still calls it once.
691
+ *
692
+ * D10/T7.6 play-state addendum: when `Game.play.paused` is true, every
693
+ * `pausable` (and non-`drivesOwnLoop`) world skips every phase EXCEPT
694
+ * `render` (still called, every substep, with `dt` forced to `0`) and skips
695
+ * its `endFrame`/opaque-`update` call entirely — a `pausable: false` world
696
+ * is completely unaffected. `Game.play.step()` drives this function via the
697
+ * internal-only `onlyFrozen` mode (see `runFrameImpl` — not part of this
698
+ * public, host-facing signature): it ticks exactly the currently
699
+ * frozen set (host-driven, `pausable`, and `paused`) through every phase +
700
+ * `endFrame` with the real `dt` (not the render-phase's forced `0`), and
701
+ * touches no other world at all — a natural no-op while not paused, since
702
+ * the frozen set is then empty.
703
+ *
704
+ * `opts.skipRenderPhases` omits `preRender`+`render` from
705
+ * the pass — every other phase, and `endFrame`, run exactly as always. A
706
+ * host that drives presentation at DISPLAY rate sets it on every substep
707
+ * and calls {@link runRenderFrame} once per real frame instead
708
+ * (`create-runtime.ts`). A host that does
709
+ * not set it renders inside the substep — which is why
710
+ * `externalDrive` capture (`render-control.ts`'s `simulateSubsteps`, which
711
+ * calls `runFrame(fixedDt)` with no opts) is frame-exact.
712
+ * `runTicks` sets it per tick for its `render: 'none' | 'last'` modes.
713
+ */
714
+ runFrame(dt: number, opts?: { skipRenderPhases?: boolean }): void;
715
+ /**
716
+ * Run ONE display frame's presentation pass: the registered
717
+ * `onRenderStep` callbacks, then the `preRender` and `render` phases across
718
+ * game-scoped systems and every host-driven world, in the same
719
+ * game-systems-then-worlds order {@link runFrame} uses.
720
+ *
721
+ * ```
722
+ * for cb in onRenderStep callbacks: cb(alpha, displayDt)
723
+ * for phase in [preRender, render]:
724
+ * game.systems.runPhase(phase, displayDt)
725
+ * for world in roots (declaration order):
726
+ * if world.mounted.drivesOwnLoop: continue
727
+ * world.frame?.runPhase(phase, frozen ? 0 : displayDt)
728
+ * for opaque world with updateCadence == 'display':
729
+ * world.mounted.update?.(frozen ? 0 : displayDt)
730
+ * ```
731
+ *
732
+ * Advances NOTHING: no `tick`, no `simT`, no sim-clock flush, no `endFrame`. It is presentation only, which is what makes calling
733
+ * it at a rate the simulation does not share safe in the first place.
734
+ *
735
+ * D10's pause rule carries over unchanged in substance: a frozen world
736
+ * (paused + `pausable`) still RENDERS — that is what keeps a paused editor
737
+ * viewport painted — but with `dt` forced to `0`, so no time-based render
738
+ * effect animates a frozen scene. What changes is only the cadence: once per
739
+ * display frame instead of once per fixed substep.
740
+ */
741
+ runRenderFrame(alpha: number, displayDt: number): void;
742
+ /**
743
+ * D15/T-D15.3-.4 — a deterministic fast-forward primitive: synchronously
744
+ * call the SAME per-tick pipeline `runFrame` uses, `n` times in a tight
745
+ * loop, with `dt` fixed to the host loop's own fixed timestep
746
+ * (`this.loop.fixedDt` — `core/game-loop.ts`). This generalizes
747
+ * `render-control.ts`'s proven `VgaiRenderHarness. simulateSubsteps` from
748
+ * the capture-only door (`?vgai-render=1`) to a Game-level primitive every
749
+ * door can reach (the bridge's `window.__vgai.runTicks`, the editor relay's
750
+ * `run-ticks` case → `play.runTicks`) — `simulateSubsteps` itself is
751
+ * UNTOUCHED by this addition (it may later delegate to this method; not
752
+ * this unit's job).
753
+ *
754
+ * Semantics:
755
+ * - **Decoupled from wall clock and the accumulator.** `runTicks` drives
756
+ * `runFrame` DIRECTLY — it never goes through `GameLoop`'s own
757
+ * accumulator/spiral-of-death drop path, so `n` ticks always advance
758
+ * `tick`/`simT` by exactly `n`/`n * fixedDt`, on any hardware, regardless
759
+ * of how slow or fast the call actually took wall-clock-wise. Single-
760
+ * threaded-burst note: because JS has one thread, this synchronous burst
761
+ * can never interleave with a real rAF frame — but if the host's own
762
+ * loop is still running (`loop.start()`), wall-clock time keeps accruing
763
+ * in ITS accumulator while this call executes; the NEXT rAF frame after
764
+ * the burst sees that gap and the loop's existing `maxAccumulator` clamp
765
+ * absorbs it exactly as it would absorb any other slow-frame gap (up to
766
+ * 8 substeps, additional time dropped) — `runTicks` does not need to
767
+ * (and does not) touch the accumulator itself to make this safe.
768
+ * One consequence worth its own sentence: because the burst is one
769
+ * synchronous JS turn, no React commit can interleave it — a scene
770
+ * RELOAD triggered inside the burst (a translated
771
+ * `reload_current_scene`, a `SceneManager.LoadScene`) leaves the
772
+ * incoming world unmounted for the burst's remaining ticks, which the
773
+ * outgoing world's replacement therefore never simulates. Measured on
774
+ * the starter-kit port: a 20-tick burst spanning a reload left the
775
+ * fresh scene with zero ticks (its census had no player), while the
776
+ * same 20 ticks driven as 1-tick calls interleaved the commit and
777
+ * matched the source engine, whose reloads happen between frames. A
778
+ * caller that can trigger remounts mid-window drives 1-tick bursts
779
+ * (the fidelity driver's `advanceTicks` is the worked example).
780
+ * - **`opts.render`** (default `'last'`): `'last'` skips the `preRender`/
781
+ * `render` phases for ticks `0..n-2` and runs the full phase list
782
+ * (including `preRender`/`render`) on the final tick only — the GGPO
783
+ * tick-without-render pattern. `'all'` renders every tick. `'none'`
784
+ * never renders, not even the last tick. `tick`/`simT`/the debug event
785
+ * ring advance identically on EVERY tick regardless of `render` — only the
786
+ * paint-affecting phases are skipped, so debug state providers stay correct
787
+ * even when fast-forwarding with no visible output.
788
+ * - **Refuses while paused.** Throws a structured `DebugError`
789
+ * (`code: 'RUN_TICKS_PAUSED'`) if `Game.play.paused` is true —
790
+ * `Game.play.step()` owns stepping the frozen set; `runTicks` is a
791
+ * running-game primitive, not a paused-world stepper, and silently
792
+ * no-op-ing or silently ignoring pause would violate the "byte-identical
793
+ * across doors" contract this primitive exists to provide.
794
+ * - **Does not bypass the input focus gate.** Every tick runs the same
795
+ * frame every other call to `runFrame` does; `runTicks` has no
796
+ * special-cased "force focus" behavior.
797
+ */
798
+ runTicks(n: number, opts?: RunTicksOptions): void;
799
+ /** Release game-owned resources after every mounted root has disposed. */
800
+ dispose(): void;
801
+ }
802
+
803
+ /**
804
+ * Construct the (host-internal) Game shell. Callers: `createGameRuntime`
805
+ * builds this BEFORE mounting its one adapter, then registers the default
806
+ * three world once mount resolves (see `registerThreeRoot` in
807
+ * `create-runtime.ts`).
808
+ */
809
+ export function createGame(opts: {
810
+ loop: GameLoop;
811
+ assets: AssetCache;
812
+ playtest?: PlaytestContext | null | undefined;
813
+ /** D15 (T-D15.1) — the root seed `ctx.random` boots from, on every world
814
+ * mounted onto this Game. Defaults to `DEFAULT_SEEDED_RANDOM_SEED` (a
815
+ * fixed, non-wall-clock constant — `ctx.random` is always reproducible
816
+ * on its own terms, whether or not the project's manifest DECLARES that
817
+ * reproducibility as a contract). The manifest-aware boot path
818
+ * (`mount-manifest.ts`'s `mountManifestRoots`) is what actually resolves
819
+ * `manifest.determinism.defaultSeed`/`?vgai-seed=`/explicit config and
820
+ * passes the result here, BEFORE any world's `mount()`/`setup()` runs —
821
+ * `createGameRuntime` always constructs the Game (this call) first (see
822
+ * this function's own doc comment below). */
823
+ seed?: number | undefined;
824
+ }): GameInternal {
825
+ const roots: RootInstance[] = [];
826
+ const profiler = createPerformanceProfiler();
827
+ const systems = createSystemRunner(profiler.systemObserver, 'game');
828
+ // D15 (T-D15.1) — the game-scoped seeded-random surface every world's
829
+ // `ctx.random` aliases (see `editor-game/src/host/roots/r3f-root.tsx`'s `ctx.random =
830
+ // ...`, wired the same way `ctx.debug` is just below). Constructed
831
+ // unconditionally (cheap — a handful of closures) regardless of whether
832
+ // this project ever declares `determinism.seededRandom`; only the BOOT
833
+ // SEED and the enforcement (the burn-down scan, the trap right below) are
834
+ // conditional on that declaration.
835
+ const seededRandom = createSeededRandom(opts.seed ?? DEFAULT_SEEDED_RANDOM_SEED);
836
+ // D15 (T-D15.3) — the dev-mode `Math.random` phase trap. Constructed
837
+ // unconditionally too (disabled by default: `rngTrapEnabled` starts
838
+ // `false`, so `runFrameImpl`'s enable/disable calls below are no-ops) —
839
+ // the manifest-aware boot path flips `rngTrapControl.setEnabled(true)`
840
+ // once it resolves `manifest.determinism?.seededRandom` (mirrors
841
+ // `debugRegistry.setRoomDeclared` being flipped post-hoc from the same
842
+ // boot path for the very same "only the caller who read the manifest
843
+ // knows" reason).
844
+ const rngTrap = createGameplayRngTrap();
845
+ let rngTrapEnabled = false;
846
+ const rngTrapControl = {
847
+ setEnabled(enabled: boolean): void {
848
+ rngTrapEnabled = enabled;
849
+ },
850
+ get enabled(): boolean {
851
+ return rngTrapEnabled;
852
+ },
853
+ };
854
+ // `tick` counts completed fixed substeps, `simT` accumulates their `dt` —
855
+ // both game-scoped, advanced ONLY under `advanced` (`runFrame`'s tail below), so a paused/frozen frame never
856
+ // advances either. The debug registry reads them via suppliers (not by
857
+ // capturing the numbers now) so its built-in `time` provider always sees
858
+ // the CURRENT values.
859
+ let tick = 0;
860
+ let simT = 0;
861
+ // The display-rate half. `renderAlpha` is the last alpha
862
+ // `runRenderFrameImpl` presented at (0 until a host drives one); the set is
863
+ // the `RenderStepped`-shaped registry `Game.onRenderStep` feeds. Deliberately
864
+ // NOT beside `tick`/`simT` in meaning: neither of these ever advances sim
865
+ // state, which is exactly what makes running them at the monitor's rate safe.
866
+ let renderAlpha = 0;
867
+ const renderStepCallbacks = new Set<(alpha: number, displayDt: number) => void>();
868
+ // P3 — the ONE sim clock this Game owns, declared beside the accumulator it
869
+ // is bound to (`flush(simT)` runs at the tail of `runFrameImpl`, inside the
870
+ // same `advanced` guard as the two bumps above, so a paused/frozen frame
871
+ // fires no timers). Every world's `ctx.clock` is THIS instance, reached the
872
+ // same way `ctx.random`/`ctx.debug` reach their game-scoped singletons —
873
+ // `getSimClock(host.game)` over the game-scoped slot filed below.
874
+ //
875
+ // The disposer is supplied HERE rather than inside the clock because
876
+ // `core/sim-clock.ts` deliberately knows nothing about Rapier or shared
877
+ // geometry. It mirrors `editor-game/src/host/roots/r3f-root.tsx`'s world teardown
878
+ // ordering for ONE subtree.
879
+ const simClock: SimClockInternal = createSimClock({
880
+ // Every mount that could own part of the subtree is offered it:
881
+ // `physics.get` is a no-op for a node the mount does not own, so this is
882
+ // correct with several three roots on one Game and needs no ownership
883
+ // bookkeeping.
884
+ dispose: (obj) => disposeDebrisSubtree(obj, roots.flatMap(debrisOwnersOf)),
885
+ });
886
+ const debugRegistry = createDebugRegistry({
887
+ getTick: () => tick,
888
+ getSimT: () => simT,
889
+ getFixedDt: () => opts.loop.fixedDt,
890
+ // D15/T-D15.5 — "the manifest's first/default world" for the debug
891
+ // registry's world-addressed input-target surface: the root an unaddressed
892
+ // `game.input.*` call reaches when several roots export an input door.
893
+ // Use the SAME "first three world, else first world" rule
894
+ // `requireDefaultRoot` (declared just below — safe: this closure is
895
+ // only ever CALLED later, once at least one world has mounted) already
896
+ // defines for `Game.defaultRoot`.
897
+ getDefaultRootId: () => (roots.length > 0 ? requireDefaultRoot().id : null),
898
+ // Issue #175 — the built-in `time` provider's `loopLiveness` field reads
899
+ // the REAL loop, not any UI-level play-state store: `opts.loop` is the
900
+ // SAME `GameLoop` this `Game`'s own `.loop` field exposes, so this
901
+ // registry can never disagree with `game.loop.liveness` about whether
902
+ // the loop is actually ticking.
903
+ getLoopLiveness: () => opts.loop.liveness,
904
+ });
905
+ /** Whether any world advances this frame — the game-owned systems run only then. */
906
+ let worldsAdvancing = false;
907
+
908
+ function requireDefaultRoot(): RootInstance {
909
+ if (roots.length === 0) {
910
+ throw new Error('Game.defaultRoot: no roots registered yet');
911
+ }
912
+ return roots.find((w) => w.kind === 'three') ?? roots[0]!;
913
+ }
914
+
915
+ // --- Game.systemAdapters aggregation (§7.1-3, probe1) --------------------
916
+ // Warn-once-per-colliding-key state, scoped to this Game instance (a fresh
917
+ // Game gets a fresh warn history) — deliberately NOT reset by anything
918
+ // short of a new `createGame` call, matching `reportedGateShortfalls`
919
+ // above's "once per game instance" idiom.
920
+ const warnedSystemAdapterKeys = new Set<string>();
921
+ const systemAdapterListeners = new Set<() => void>();
922
+ /** Per-root slots installed by `installDeclaredSystemAdapters` — the native
923
+ * module surface's static `systems` declaration. Merged BEFORE the same
924
+ * root's live `mounted.systems` below, so during migration a lingering
925
+ * component registration for a declared slot is shadowed (with the
926
+ * standard warning) rather than silently winning by running later. */
927
+ const declaredSystemAdapters = new Map<string, Readonly<Partial<SystemAdapters>>>();
928
+ /** Per-root POSITIVE ABSENCES from the same declaration — `absent(reason)`
929
+ * slots. Nothing binds them (there is nothing to bind); they are kept so a
930
+ * reader can tell an answered absence from an unasked question. */
931
+ const declaredAbsences = new Map<string, readonly DeclaredSystemAbsence[]>();
932
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-source × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
933
+ function computeSystemAdapters(): SystemAdapters {
934
+ // Debug is game-scoped: React hooks, probes, and the built-in time
935
+ // provider all register with the one registry created above, regardless
936
+ // of whether any mounted root happens to expose a `systems` object. A
937
+ // root may still publish this SAME adapter (the first-party Three/Pixi
938
+ // mounts do); the reference-equality branch below treats that as the
939
+ // intentional shared registration it is.
940
+ const result: SystemAdapters = { debug: debugRegistry.adapter };
941
+ const ownerRootId = new Map<string, string>([['debug', '(game)']]);
942
+ const merge = (
943
+ owner: string,
944
+ adapters: Readonly<Partial<SystemAdapters>>,
945
+ quietKeys?: ReadonlySet<string>,
946
+ ): void => {
947
+ for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
948
+ if (adapters[key] === undefined) continue;
949
+ const existingOwner = ownerRootId.get(key);
950
+ if (existingOwner !== undefined) {
951
+ // Reference-equality short-circuit: two roots sharing the ONE
952
+ // game-scoped debug registry's adapter (T1.1) both expose the SAME
953
+ // object under `systems.debug` — that is by design, not a
954
+ // collision, so it must never warn.
955
+ if (result[key] === adapters[key]) continue;
956
+ // A world's own DECLARED slot overriding that same world's
957
+ // substrate-seeded default (the three lane pre-seeds physics/audio/
958
+ // renderDebug into `mounted.systems`) is the door working as
959
+ // intended, not a collision — same silence `ctx.
960
+ // registerSystemAdapter` gives the same override. Cross-root
961
+ // collisions still warn.
962
+ if (quietKeys?.has(key)) continue;
963
+ if (!warnedSystemAdapterKeys.has(key)) {
964
+ warnedSystemAdapterKeys.add(key);
965
+ // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
966
+ console.warn(
967
+ `[game] systemAdapters: "${key}" is registered by both ${existingOwner} and ` +
968
+ `${owner} — the FIRST registration (${existingOwner}) wins; the later ` +
969
+ 'one is shadowed (game-scoped system adapters).',
970
+ );
971
+ }
972
+ continue;
973
+ }
974
+ // biome-ignore lint/suspicious/noExplicitAny: SystemAdapters is a plain optional-field record; the per-key copy is correct by construction (same key on both sides), just not expressible without a cast
975
+ (result as any)[key] = adapters[key];
976
+ ownerRootId.set(key, owner);
977
+ }
978
+ };
979
+ for (const world of roots) {
980
+ const declared = declaredSystemAdapters.get(world.id);
981
+ if (declared) merge(`world "${world.id}" (declared systems export)`, declared);
982
+ const adapters = world.mounted.systems;
983
+ if (adapters) {
984
+ merge(
985
+ `world "${world.id}"`,
986
+ adapters,
987
+ declared ? new Set(Object.keys(declared)) : undefined,
988
+ );
989
+ }
990
+ }
991
+ return result;
992
+ }
993
+
994
+ // --- D10/T7.6 play-state (Game.play) -------------------------------------
995
+ let paused = false;
996
+ // Dedupe loop-gate/audio-gate shortfall warnings to ONCE per (world, kind)
997
+ // — `pause()` fans out over every world every call; without this a
998
+ // multi-second play session would re-log the same "can't gate" shortfall
999
+ // every time the user hits Pause.
1000
+ const reportedGateShortfalls = new Set<string>();
1001
+ function reportGateShortfallOnce(kind: 'loop' | 'audio', worldId: string, reason: string): void {
1002
+ const key = `${kind}:${worldId}`;
1003
+ if (reportedGateShortfalls.has(key)) return;
1004
+ reportedGateShortfalls.add(key);
1005
+ const message =
1006
+ kind === 'loop'
1007
+ ? formatLoopGateMessage({ worldId, reason })
1008
+ : formatAudioGateMessage({ worldId, reason });
1009
+ // Other native `console.warn`/`console.error` call sites in this file are
1010
+ // unsuppressed and already counted in the lint baseline (see `runFrame`'s
1011
+ // impl below); this one is a NEW site, so it's suppressed to keep this
1012
+ // task's diff at zero NEW warnings.
1013
+ // biome-ignore lint/suspicious/noConsole: see comment above
1014
+ console.warn(message);
1015
+ }
1016
+
1017
+ const audioMuteBeforePause = new WeakMap<AudioAdapter, boolean>();
1018
+
1019
+ /** Fan out a loop-gate (self-driven roots) + audio-gate call over every
1020
+ * `pausable` world, reporting honestly (once) wherever the capability is
1021
+ * absent — shared by `pause()`/`resume()` below (same fan-out, opposite
1022
+ * boolean). */
1023
+ // 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
1024
+ function setRootGates(next: boolean): void {
1025
+ for (const world of roots) {
1026
+ // A disposed world stays in `roots` (there is no `unregisterRoot`), and
1027
+ // its adapter's `setPaused`/`systems.audio` reach a renderer, ticker or
1028
+ // audio graph that `dispose()` already released — the same reason
1029
+ // `runFrame`/`runRenderFrame`/`runTicks` skip it. Without this, a
1030
+ // pause/resume after a partial teardown calls into freed resources, and
1031
+ // the audio leg re-mutes a context that no longer exists.
1032
+ if (world.disposed) continue;
1033
+ if (!world.pausable) continue; // pausable:false roots are untouched by design
1034
+ if (world.mounted.drivesOwnLoop) {
1035
+ if (world.mounted.setPaused) {
1036
+ world.mounted.setPaused(next);
1037
+ } else if (next) {
1038
+ reportGateShortfallOnce(
1039
+ 'loop',
1040
+ world.id,
1041
+ 'self-driven world, adapter declares no setPaused capability',
1042
+ );
1043
+ }
1044
+ }
1045
+ // A native entry's static `systems` export is this root's primary
1046
+ // declaration surface. `computeSystemAdapters()` already gives it
1047
+ // precedence over a substrate-seeded mounted adapter; pause must read
1048
+ // the same source or a valid entry-declared audio graph is visible to
1049
+ // the editor yet paradoxically reported as ungateable here.
1050
+ const audio = declaredSystemAdapters.get(world.id)?.audio ?? world.mounted.systems?.audio;
1051
+ if (audio) {
1052
+ if (next) {
1053
+ if (!audioMuteBeforePause.has(audio)) audioMuteBeforePause.set(audio, audio.isMuted());
1054
+ audio.setMuted(true);
1055
+ } else if (audioMuteBeforePause.has(audio)) {
1056
+ audio.setMuted(audioMuteBeforePause.get(audio)!);
1057
+ audioMuteBeforePause.delete(audio);
1058
+ }
1059
+ } else if (
1060
+ next &&
1061
+ !declaredAbsences.get(world.id)?.some((absence) => absence.slot === 'audio')
1062
+ ) {
1063
+ reportGateShortfallOnce('audio', world.id, 'no SystemAdapters.audio on this world');
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/roots — splitting it would obscure the ordering contract documented on GameInternal.runFrame
1069
+ function runFrameImpl(
1070
+ dt: number,
1071
+ frameOpts?: { onlyFrozen?: boolean; skipRenderPhases?: boolean },
1072
+ ): void {
1073
+ // D15 (T-D15.3) — brackets the ENTIRE frame body (every phase, every
1074
+ // world, both the `onlyFrozen` and normal branches below converge on the
1075
+ // single `profiler.endFrame()` at the tail) with zero reordering of the
1076
+ // phase algorithm itself — a no-op pair of calls while
1077
+ // `rngTrapEnabled` is false (the common case: most projects never
1078
+ // declare `determinism.seededRandom`).
1079
+ if (rngTrapEnabled) rngTrap.enable();
1080
+ profiler.beginFrame();
1081
+ // The render-phase skip. Two callers set it, for the
1082
+ // same reason — this substep is not the thing that paints. `runTicks`'s
1083
+ // `render: 'none'|'last'` fast-forward sets it on every tick it doesn't
1084
+ // want to paint (see `runTicks`'s doc comment on `GameInternal`), and a
1085
+ // DISPLAY-RATE host sets it on every substep because `runRenderFrame`
1086
+ // paints once per real frame instead. Unlike `onlyFrozen` below it is not
1087
+ // internal-only: it is part of the public `GameInternal.runFrame`
1088
+ // signature, since an external host is one of those two callers.
1089
+ const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
1090
+ // `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
1091
+ // signature — no external caller sets it) — `Game.play.step()` below is the
1092
+ // one and only caller. It ticks EXACTLY the currently-frozen set (host-driven,
1093
+ // `pausable`, and `paused`) through every phase + `endFrame`, with the REAL
1094
+ // `dt` (not the render-phase's forced `0`), and touches no other world at all
1095
+ const onlyFrozen = frameOpts?.onlyFrozen ?? false;
1096
+ // Checklist item 7: snapshot the world count ONCE at entry and iterate
1097
+ // by index in both loops below. A world registered mid-frame (e.g. from
1098
+ // a game-scoped system's side effect) joins at the NEXT `runFrame` call,
1099
+ // not this one — `for (const world of roots)` would otherwise pick up
1100
+ // a world pushed during this very frame. This also drops the two
1101
+ // per-phase `for...of` iterator allocations.
1102
+ const n = roots.length;
1103
+ worldsAdvancing = false;
1104
+ for (let i = 0; i < n; i++) {
1105
+ const world = roots[i]!;
1106
+ if (world.disposed) continue;
1107
+ if (onlyFrozen) {
1108
+ if (paused && world.pausable && !world.mounted.drivesOwnLoop) {
1109
+ worldsAdvancing = true;
1110
+ break;
1111
+ }
1112
+ } else if (
1113
+ world.mounted.drivesOwnLoop
1114
+ ? !paused || !world.pausable || !world.mounted.setPaused
1115
+ : !paused || !world.pausable
1116
+ ) {
1117
+ worldsAdvancing = true;
1118
+ break;
1119
+ }
1120
+ }
1121
+
1122
+ // §7.1-11 fix (probe5): the frame's tail (tick, sim time, timers) runs iff
1123
+ // at least one world actually advanced this call — not unconditionally. `advanced`
1124
+ // covers rule (a) below (host-driven roots this call actually ticked);
1125
+ // rule (b): a self-driven world that is RUNNING — which is every
1126
+ // self-driven world while not paused, and, while paused, the ones the
1127
+ // gate can't reach (`pausable: false`, or no `setPaused` capability) —
1128
+ // checked once, up front, over the same `roots` array (no extra
1129
+ // allocation, matching every other loop here). Rule (b) is skipped in
1130
+ // `onlyFrozen` mode: a `step()` call advances iff it ticked a frozen world
1131
+ // — self-driven notifications belong to the loop's own `runFrame`s.
1132
+ let advanced = false;
1133
+ if (!onlyFrozen) {
1134
+ for (let i = 0; i < n; i++) {
1135
+ const world = roots[i]!;
1136
+ if (world.disposed) continue;
1137
+ if (!world.mounted.drivesOwnLoop) continue;
1138
+ if (!paused || !world.pausable || !world.mounted.setPaused) {
1139
+ advanced = true;
1140
+ break;
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ if (onlyFrozen) {
1146
+ // step(): the frozen set is host-driven + pausable + currently paused.
1147
+ // While NOT paused this set is empty by construction, so this whole
1148
+ // branch is a natural no-op — `Game.play.step()`'s "no-op while
1149
+ // running" behavior falls straight out of this, no separate guard
1150
+ // needed.
1151
+ for (const phase of PHASE_ORDER) {
1152
+ profiler.beginPhase();
1153
+ // Game-owned systems follow the same one-run-per-phase contract as a
1154
+ // normal frame whenever Step advances at least one frozen world.
1155
+ if (worldsAdvancing) systems.runPhase(phase, dt);
1156
+ for (let i = 0; i < n; i++) {
1157
+ const world = roots[i]!;
1158
+ if (world.disposed) continue;
1159
+ if (world.mounted.drivesOwnLoop) continue;
1160
+ if (!(paused && world.pausable)) continue; // only the frozen set
1161
+ try {
1162
+ world.frame?.runPhase(phase, dt);
1163
+ } catch (err) {
1164
+ console.error(
1165
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
1166
+ '(step()):',
1167
+ err,
1168
+ );
1169
+ }
1170
+ }
1171
+ profiler.endPhase(phase);
1172
+ }
1173
+ for (let i = 0; i < n; i++) {
1174
+ const world = roots[i]!;
1175
+ if (world.disposed) continue;
1176
+ if (world.mounted.drivesOwnLoop) continue;
1177
+ if (!(paused && world.pausable)) continue;
1178
+ advanced = true;
1179
+ if (world.frame) {
1180
+ try {
1181
+ world.frame.endFrame?.();
1182
+ } catch (err) {
1183
+ console.error(
1184
+ `[game] world "${world.id}" (kind: ${world.kind}) endFrame() threw (step()):`,
1185
+ err,
1186
+ );
1187
+ }
1188
+ } else {
1189
+ try {
1190
+ world.mounted.update?.(dt);
1191
+ } catch (err) {
1192
+ console.error(
1193
+ `[game] world "${world.id}" (kind: ${world.kind}) update() threw (step()):`,
1194
+ err,
1195
+ );
1196
+ }
1197
+ }
1198
+ }
1199
+ } else {
1200
+ for (const phase of PHASE_ORDER) {
1201
+ profiler.beginPhase();
1202
+ // D15/T-D15.4: `runTicks`'s fast-forward skip — `preRender`/`render`
1203
+ // are the ONLY phases ever skipped this way (every earlier gameplay
1204
+ // phase, and `endFrame` below, always run) — see `skipRenderPhases`'s
1205
+ // declaration above and `runTicks`'s doc comment on `GameInternal`.
1206
+ const skipThisPhase =
1207
+ skipRenderPhases && (phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER);
1208
+ if (!skipThisPhase) {
1209
+ systems.runPhase(phase, dt);
1210
+ for (let i = 0; i < n; i++) {
1211
+ const world = roots[i]!;
1212
+ if (world.disposed) continue;
1213
+ if (world.mounted.drivesOwnLoop) continue;
1214
+ // D10/T7.6: a `pausable` world under an active pause
1215
+ // skips every phase except `render` — its render still runs, every
1216
+ // substep, but with `dt` forced to `0` (deterministic: no
1217
+ // time-based render effect silently keeps animating a "frozen"
1218
+ // scene). A `pausable: false` world is unaffected.
1219
+ const frozen = paused && world.pausable;
1220
+ if (frozen && phase !== SystemPhase.RENDER) continue;
1221
+ const phaseDt = frozen ? 0 : dt;
1222
+ // Checklist item 2: isolate each world's per-phase work — one
1223
+ // world's `runPhase` throwing must not starve sibling roots still
1224
+ // due this phase, nor abort the frame. Mirrors `runOne`'s style in
1225
+ // `core/system-runner.ts` (loud console.error, never swallowed).
1226
+ try {
1227
+ world.frame?.runPhase(phase, phaseDt);
1228
+ } catch (err) {
1229
+ console.error(
1230
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw:`,
1231
+ err,
1232
+ );
1233
+ }
1234
+ }
1235
+ }
1236
+ profiler.endPhase(phase);
1237
+ }
1238
+ for (let i = 0; i < n; i++) {
1239
+ const world = roots[i]!;
1240
+ if (world.disposed) continue;
1241
+ if (world.mounted.drivesOwnLoop) continue;
1242
+ // A fully-frozen world gets no `endFrame`/opaque-`update` call either —
1243
+ // there is nothing to "end the frame" of when nothing ran this substep.
1244
+ const frozen = paused && world.pausable;
1245
+ if (frozen) continue;
1246
+ advanced = true;
1247
+ if (world.frame) {
1248
+ try {
1249
+ world.frame.endFrame?.();
1250
+ } catch (err) {
1251
+ console.error(
1252
+ `[game] world "${world.id}" (kind: ${world.kind}) endFrame() threw:`,
1253
+ err,
1254
+ );
1255
+ }
1256
+ } else {
1257
+ // Checklist item 2: same isolation for the opaque-world fallback —
1258
+ // one foreign mount's `update` throwing must not starve its
1259
+ // siblings' `endFrame`/`update` calls this same loop.
1260
+ // A display-cadence opaque update is the root's indivisible native
1261
+ // presentation (R3F `advance()` is callbacks + draw). The real
1262
+ // rAF host withholds render phases from EVERY fixed catch-up step;
1263
+ // withhold this update beside them, then run it once in
1264
+ // `runRenderFrameImpl`. A direct/offline `runFrame` includes render
1265
+ // phases and therefore still advances the root exactly once.
1266
+ if (!(skipRenderPhases && world.mounted.updateCadence === 'display')) {
1267
+ try {
1268
+ world.mounted.update?.(dt);
1269
+ } catch (err) {
1270
+ console.error(
1271
+ `[game] world "${world.id}" (kind: ${world.kind}) update() threw:`,
1272
+ err,
1273
+ );
1274
+ }
1275
+ }
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ // The frame's tail runs LAST, after every phase of every world and every
1281
+ // world's endFrame/update above, at most once per completed
1282
+ // `runFrame`/`step()` call and, per §7.1-11's fix, only when `advanced`
1283
+ // (see above) is true: a fully-gated paused game advances nothing.
1284
+ if (advanced) {
1285
+ tick++;
1286
+ simT += dt;
1287
+ // P3 — sim timers fire IMMEDIATELY after the bump, inside this same
1288
+ // `advanced` guard: a paused/frozen world advances no sim time, so it
1289
+ // must fire no timers either, and `Game.play.step()` (which reaches this
1290
+ // block with `advanced` set by the frozen-set loop above) fires exactly
1291
+ // the timers that ONE substep makes due. One flush === one completed
1292
+ // substep, which is what lets `SimClock.tickNow()` simply count flushes
1293
+ // and always agree with `tick`.
1294
+ simClock.flush(simT);
1295
+ }
1296
+ profiler.endFrame();
1297
+ if (rngTrapEnabled) rngTrap.disable();
1298
+ }
1299
+
1300
+ /**
1301
+ * One display frame's PRESENTATION pass. See
1302
+ * `GameInternal.runRenderFrame`'s doc comment for the contract; this is the
1303
+ * `preRender`+`render` slice of `runFrameImpl`'s phase loop, lifted out and
1304
+ * driven by the loop's own per-real-frame callback instead of by the substep
1305
+ * loop.
1306
+ *
1307
+ * Three deliberate parallels with `runFrameImpl`, so the two passes cannot
1308
+ * drift into different rules for the same situation:
1309
+ * - game-scoped systems run before per-world hooks, per phase;
1310
+ * - one world's throw is logged and isolated, never allowed to starve a
1311
+ * sibling still due this phase;
1312
+ * - the RNG trap brackets the whole pass, so `Math.random` called from a
1313
+ * render-phase system is still caught by D15's dev-mode trap — it was,
1314
+ * back when this pass lived inside `runFrame`, and moving code must not
1315
+ * quietly move it out from under a guard.
1316
+ *
1317
+ * Profiler note (a real, accepted consequence): the profiler's "frame" has
1318
+ * always meant "one `runFrame` call", so a display frame now produces the
1319
+ * substep records it always did PLUS one record for this pass, carrying the
1320
+ * `preRender`/`render` spans and the renderer counters. Re-modelling the
1321
+ * profiler's frame boundary around the display frame is a separate change to
1322
+ * a debugging surface, not part of flipping the loop.
1323
+ */
1324
+ function runRenderFrameImpl(alpha: number, displayDt: number): void {
1325
+ renderAlpha = alpha;
1326
+ if (rngTrapEnabled) rngTrap.enable();
1327
+ profiler.beginFrame();
1328
+
1329
+ // `RenderStepped` first: a camera adjusted here is drawn by THIS frame's
1330
+ // render phase, not next frame's. Iterated over a snapshot so a callback
1331
+ // that unsubscribes itself (or registers another) cannot mutate the set
1332
+ // mid-iteration.
1333
+ if (renderStepCallbacks.size > 0) {
1334
+ for (const fn of [...renderStepCallbacks]) {
1335
+ try {
1336
+ fn(alpha, displayDt);
1337
+ } catch (err) {
1338
+ console.error('[game] an onRenderStep callback threw:', err);
1339
+ }
1340
+ }
1341
+ }
1342
+
1343
+ const n = roots.length;
1344
+ for (const phase of DISPLAY_RATE_PHASES) {
1345
+ profiler.beginPhase();
1346
+ systems.runPhase(phase, displayDt);
1347
+ for (let i = 0; i < n; i++) {
1348
+ const world = roots[i]!;
1349
+ if (world.disposed) continue;
1350
+ if (world.mounted.drivesOwnLoop) continue;
1351
+ // D10/T7.6, carried over verbatim in substance: a frozen world still
1352
+ // renders (a paused viewport stays painted) but with `dt` forced to
1353
+ // `0`, and skips every other phase.
1354
+ const frozen = paused && world.pausable;
1355
+ if (frozen && phase !== SystemPhase.RENDER) continue;
1356
+ try {
1357
+ world.frame?.runPhase(phase, frozen ? 0 : displayDt);
1358
+ } catch (err) {
1359
+ console.error(
1360
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
1361
+ '(display frame):',
1362
+ err,
1363
+ );
1364
+ }
1365
+ }
1366
+ profiler.endPhase(phase);
1367
+ }
1368
+
1369
+ // An opaque display-cadence root cannot expose phase hooks because its
1370
+ // native advance is inseparable. It still belongs to THIS presentation
1371
+ // frame, once — never to every fixed catch-up substep that preceded it.
1372
+ // A frozen world redraws at dt=0, preserving the established pause rule.
1373
+ for (let i = 0; i < n; i++) {
1374
+ const world = roots[i]!;
1375
+ if (world.disposed) continue;
1376
+ if (world.mounted.drivesOwnLoop) continue;
1377
+ if (world.frame) continue;
1378
+ if (world.mounted.updateCadence !== 'display') continue;
1379
+ const frozen = paused && world.pausable;
1380
+ try {
1381
+ world.mounted.update?.(frozen ? 0 : displayDt);
1382
+ } catch (err) {
1383
+ console.error(
1384
+ `[game] world "${world.id}" (kind: ${world.kind}) update() threw (display frame):`,
1385
+ err,
1386
+ );
1387
+ }
1388
+ }
1389
+
1390
+ profiler.endFrame();
1391
+ if (rngTrapEnabled) rngTrap.disable();
1392
+ }
1393
+
1394
+ const gameInternal: GameInternal = {
1395
+ loop: opts.loop,
1396
+ assets: opts.assets,
1397
+ playtest: opts.playtest ?? null,
1398
+ profiler,
1399
+ systems,
1400
+ get roots() {
1401
+ return roots;
1402
+ },
1403
+ world(id: string): RootInstance | null {
1404
+ return roots.find((w) => w.id === id) ?? null;
1405
+ },
1406
+ get defaultRoot() {
1407
+ return requireDefaultRoot();
1408
+ },
1409
+ get systemAdapters() {
1410
+ return computeSystemAdapters();
1411
+ },
1412
+ subscribeSystemAdapters(listener: () => void) {
1413
+ systemAdapterListeners.add(listener);
1414
+ return () => systemAdapterListeners.delete(listener);
1415
+ },
1416
+ notifySystemAdaptersChanged() {
1417
+ for (const listener of systemAdapterListeners) listener();
1418
+ },
1419
+ get declaredSystemAbsences() {
1420
+ return [...declaredAbsences.values()].flat();
1421
+ },
1422
+ installDeclaredSystemAdapters(
1423
+ rootId: string,
1424
+ slots: Readonly<Partial<SystemAdapters>>,
1425
+ absent: readonly DeclaredSystemAbsence[] = [],
1426
+ ) {
1427
+ const root = roots.find((w) => w.id === rootId);
1428
+ if (!root) {
1429
+ throw new Error(
1430
+ `installDeclaredSystemAdapters: no mounted root is named "${rootId}" ` +
1431
+ `(mounted: ${roots.map((w) => `"${w.id}"`).join(', ') || 'none'}).`,
1432
+ );
1433
+ }
1434
+ if (declaredSystemAdapters.has(rootId)) {
1435
+ throw new Error(
1436
+ `installDeclaredSystemAdapters: root "${rootId}" already installed its declared ` +
1437
+ 'systems — a declaration is static and installs exactly once per mount.',
1438
+ );
1439
+ }
1440
+ const scope = root.mounted.systemScope ?? root.mounted;
1441
+ const bound = Object.fromEntries(
1442
+ Object.entries(slots).map(([key, adapter]) => [
1443
+ key,
1444
+ adapter === undefined ? undefined : bindScopedSystem(adapter, scope),
1445
+ ]),
1446
+ ) as Partial<SystemAdapters>;
1447
+ declaredSystemAdapters.set(rootId, bound);
1448
+ // ALWAYS set, never merge: a re-install (an HMR of the entry's `systems`
1449
+ // table) that dropped an `absent()` marker must also drop the recorded
1450
+ // absence, or the coverage report keeps printing a game statement the
1451
+ // game no longer makes.
1452
+ declaredAbsences.set(rootId, absent);
1453
+ gameInternal.notifySystemAdaptersChanged();
1454
+ },
1455
+ play: {
1456
+ get paused() {
1457
+ return paused;
1458
+ },
1459
+ pause() {
1460
+ if (paused) return;
1461
+ paused = true;
1462
+ setRootGates(true);
1463
+ },
1464
+ resume() {
1465
+ if (!paused) return;
1466
+ paused = false;
1467
+ setRootGates(false);
1468
+ },
1469
+ step(dt = 1 / 60) {
1470
+ // Self-driven pausable roots advance via their adapter's `step()`
1471
+ // capability — but only the ones that are actually FROZEN: the game
1472
+ // must be paused, and the world must have been gate-able in the
1473
+ // first place (`setPaused` present — a world the gate couldn't reach
1474
+ // never stopped, so "stepping" it would double-tick a still-running
1475
+ // loop, the same §7.1-2 class as the host-driven fix below). While
1476
+ // not paused, nothing here runs — step() is a whole-call no-op.
1477
+ if (paused) {
1478
+ for (const world of roots) {
1479
+ // Same disposed-world guard `runFrameImpl` (and `setRootGates`)
1480
+ // make: a disposed world's `mounted.step` drives a loop whose
1481
+ // resources are already freed.
1482
+ if (world.disposed) continue;
1483
+ if (!world.pausable || !world.mounted.drivesOwnLoop) continue;
1484
+ if (!world.mounted.setPaused) continue; // never gated — still running
1485
+ if (world.mounted.step) {
1486
+ world.mounted.step();
1487
+ } else {
1488
+ reportGateShortfallOnce(
1489
+ 'loop',
1490
+ world.id,
1491
+ 'self-driven world, adapter declares no step capability',
1492
+ );
1493
+ }
1494
+ }
1495
+ }
1496
+ // Tick exactly the frozen (host-driven, pausable, paused) set.
1497
+ runFrameImpl(dt, { onlyFrozen: true });
1498
+ },
1499
+ },
1500
+ registerRoot(world: RootInstance): void {
1501
+ if (roots.some((w) => w.id === world.id)) {
1502
+ throw new Error(`Game.registerRoot: duplicate world id "${world.id}"`);
1503
+ }
1504
+ // Checklist item 6: the SAME `mounted` object registered under two
1505
+ // world ids would be double-ticked by `runFrame` (its `frame.runPhase`/
1506
+ // `endFrame` called once per registration) and double-dispose-wrapped
1507
+ // (`createRootInstance` wraps `mounted.dispose` in place — a second
1508
+ // wrap would flip `disposed` and call through on ITS OWN wrapped
1509
+ // `originalDispose`, which would be harmless only if the adapter's
1510
+ // dispose happened to be idempotent; a foreign adapter
1511
+ // has no such guarantee). Reject it outright instead.
1512
+ if (roots.some((w) => w.mounted === world.mounted)) {
1513
+ throw new Error(
1514
+ `Game.registerRoot: world "${world.id}" shares its \`mounted\` object with an ` +
1515
+ `already-registered world ("${roots.find((w) => w.mounted === world.mounted)!.id}") — ` +
1516
+ 'the same mount cannot be registered twice.',
1517
+ );
1518
+ }
1519
+ roots.push(world);
1520
+ gameInternal.notifySystemAdaptersChanged();
1521
+ // T7.4 slice 2: a self-driven mount with no `observe` has no state
1522
+ // bridge at all — the editor's observations cannot read it, and
1523
+ // silently returning `undefined` forever would hide that. Report ONCE
1524
+ // per mount, at registration time, matching this file's existing
1525
+ // `[game] world "<id>" (kind: <kind>) ...` console idiom (see
1526
+ // `runFrame` below).
1527
+ //
1528
+ // §7.1-15: a `kind: 'dom'` world is exempt — it has no `observe`
1529
+ // BY DESIGN (its state is its own React tree's). Without
1530
+ // this exemption every production react world logged a false-positive
1531
+ // "no state bridge" warning at registration (probe: every real react
1532
+ // world mount), eroding the signal for a genuinely un-observable
1533
+ // ingested world.
1534
+ // A HOST-DRIVEN mount (`drivesOwnLoop: false`, notably the R3F adapter)
1535
+ // ticks inside `runFrame`, where the editor reads it directly; requiring
1536
+ // an `observe` bridge there produces a false warning.
1537
+ if (world.mounted.drivesOwnLoop && world.kind !== 'dom' && !world.mounted.observe) {
1538
+ console.warn(
1539
+ `[game] world "${world.id}" (kind: ${world.kind}, adapter: "${world.adapter.id}"): ` +
1540
+ 'no state bridge — this mounted game has no `observe` (RootStateObserver); ' +
1541
+ "the editor's observations cannot subscribe to its state.",
1542
+ );
1543
+ }
1544
+ },
1545
+ runFrame(dt: number, frameOpts?: { skipRenderPhases?: boolean }): void {
1546
+ runFrameImpl(dt, frameOpts);
1547
+ },
1548
+ runRenderFrame(alpha: number, displayDt: number): void {
1549
+ runRenderFrameImpl(alpha, displayDt);
1550
+ },
1551
+ get renderAlpha() {
1552
+ return renderAlpha;
1553
+ },
1554
+ onRenderStep(
1555
+ fn: (alpha: number, displayDt: number) => void,
1556
+ stepOpts?: { signal?: AbortSignal | undefined },
1557
+ ): () => void {
1558
+ const signal = stepOpts?.signal;
1559
+ // Already-aborted is a no-op registration, never a throw — the same
1560
+ // `AbortSignal` contract `SimClock.after` honors.
1561
+ if (signal?.aborted) return () => {};
1562
+ renderStepCallbacks.add(fn);
1563
+ let detachAbort: (() => void) | null = null;
1564
+ const unsubscribe = (): void => {
1565
+ renderStepCallbacks.delete(fn);
1566
+ detachAbort?.();
1567
+ detachAbort = null;
1568
+ };
1569
+ if (signal) {
1570
+ signal.addEventListener('abort', unsubscribe, { once: true });
1571
+ detachAbort = (): void => signal.removeEventListener('abort', unsubscribe);
1572
+ }
1573
+ return unsubscribe;
1574
+ },
1575
+ runTicks(n: number, ticksOpts?: RunTicksOptions): void {
1576
+ if (!Number.isInteger(n) || n < 0) {
1577
+ throw new RangeError(`Game.runTicks: n must be a non-negative integer, got ${n}`);
1578
+ }
1579
+ // Refuse while paused (D15 §2.b): stepping the frozen set is
1580
+ // `Game.play.step()`'s contract, not this one's — see `runTicks`'s doc
1581
+ // comment on `GameInternal` above.
1582
+ if (paused) {
1583
+ throw new DebugError(
1584
+ 'RUN_TICKS_PAUSED',
1585
+ 'Game.runTicks: refused — Game.play.paused is true; stepping the frozen set is ' +
1586
+ "Game.play.step()'s contract, not runTicks'",
1587
+ );
1588
+ }
1589
+ const render = ticksOpts?.render ?? 'last';
1590
+ const fixedDt = opts.loop.fixedDt;
1591
+ for (let i = 0; i < n; i++) {
1592
+ const isFinalTick = i === n - 1;
1593
+ const skipRenderPhases = render === 'all' ? false : render === 'none' ? true : !isFinalTick;
1594
+ runFrameImpl(fixedDt, { skipRenderPhases });
1595
+ }
1596
+ },
1597
+ dispose(): void {
1598
+ // P3 — the sim clock is GAME-scoped, so this is the only correct place to
1599
+ // dispose it: `create-runtime.ts`'s `fullCleanup` calls us after EVERY
1600
+ // root's `mounted.dispose()`, whereas a per-root teardown may be ending
1601
+ // just one sub-session while sibling roots keep running (see the
1602
+ // disposed-world guard in `runFrame`). It used to be disposed from
1603
+ // `editor-game/src/host/roots/r3f-root.tsx`'s teardown, which meant disposing one of
1604
+ // two three roots froze `now()` for the whole Game, rejected the other
1605
+ // world's pending `delay`s and turned its `after()` calls into silent
1606
+ // no-ops. Note the asymmetry that gives the bug away: `seededRandom` and
1607
+ // `debugRegistry` are game-scoped too, and per-root teardown has never
1608
+ // destroyed either — only ever `strip(this.id)`, its own slice.
1609
+ simClock.dispose();
1610
+ debugRegistry.strip();
1611
+ },
1612
+ };
1613
+
1614
+ // Filed AFTER the shell exists (the slot lives on the Game object
1615
+ // itself) so `getDebugRegistry(game)` — the react hooks' and any later
1616
+ // consumer's reach-in — works from the moment `createGame` returns.
1617
+ registerDebugRegistry(gameInternal, debugRegistry);
1618
+ // D15/T-D15.4: wire the run-ticks target the instant the Game shell exists
1619
+ // (unlike `setVirtualInputTarget`, which waits for a per-world mount, a
1620
+ // Game's own `runTicks` needs nothing else) — this is what makes
1621
+ // `window.__vgai.runTicks` (`debug-bridge.ts`) and the editor relay's
1622
+ // `run-ticks` case reach the SAME implementation `game.runTicks` above is.
1623
+ debugRegistry.setRunTicksTarget({ runTicks: gameInternal.runTicks });
1624
+ // D15 (T-D15.1/.3) — same "file after the shell exists" ordering as the
1625
+ // debug registry above: `getSeededRandom(game)`/`getGameplayRngTrapControl
1626
+ // (game)` (per-world `ctx.random` wiring, and the manifest-aware boot
1627
+ // path's post-hoc `setEnabled` call) both work from the moment
1628
+ // `createGame` returns.
1629
+ registerSeededRandom(gameInternal, seededRandom);
1630
+ registerGameplayRngTrapControl(gameInternal, rngTrapControl);
1631
+ // P3 — same "file after the shell exists" ordering: `getSimClock(game)` is
1632
+ // how every world's mount resolves `ctx.clock` to THIS game's one clock.
1633
+ registerSimClock(gameInternal, simClock);
1634
+
1635
+ return gameInternal;
1636
+ }