@vgai/engine 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. package/src/world2d/types.ts +55 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Weapon sounds — procedural combat audio.
3
+ *
4
+ * gunshot and laser are one-shot. chargeUp is continuous (play/stop).
5
+ */
6
+
7
+ function createNoiseBuffer(ctx: AudioContext, duration = 1): AudioBuffer {
8
+ const length = ctx.sampleRate * duration;
9
+ const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
10
+ const data = buffer.getChannelData(0);
11
+ for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
12
+ return buffer;
13
+ }
14
+
15
+ /** Sharp gunshot — highpass-filtered noise click. */
16
+ export function gunshot(
17
+ ctx: AudioContext,
18
+ dest: AudioNode,
19
+ options?: { pitch?: number; volume?: number; duration?: number },
20
+ ) {
21
+ const pitch = options?.pitch ?? 1200;
22
+ const volume = options?.volume ?? 0.6;
23
+ const duration = options?.duration ?? 0.08;
24
+ const noiseBuffer = createNoiseBuffer(ctx, 0.3);
25
+
26
+ const out = ctx.createGain();
27
+ out.gain.value = volume;
28
+ out.connect(dest);
29
+
30
+ return {
31
+ play() {
32
+ const now = ctx.currentTime;
33
+ const src = ctx.createBufferSource();
34
+ src.buffer = noiseBuffer;
35
+
36
+ const hp = ctx.createBiquadFilter();
37
+ hp.type = 'highpass';
38
+ hp.frequency.value = pitch;
39
+
40
+ const env = ctx.createGain();
41
+ env.gain.setValueAtTime(1, now);
42
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
43
+
44
+ src.connect(hp);
45
+ hp.connect(env);
46
+ env.connect(out);
47
+ src.start(now);
48
+ src.stop(now + duration + 0.01);
49
+ },
50
+ dispose() {
51
+ out.disconnect();
52
+ },
53
+ };
54
+ }
55
+
56
+ /** Laser — descending sawtooth frequency sweep. */
57
+ export function laser(
58
+ ctx: AudioContext,
59
+ dest: AudioNode,
60
+ options?: { startFreq?: number; endFreq?: number; volume?: number; duration?: number },
61
+ ) {
62
+ const startFreq = options?.startFreq ?? 1200;
63
+ const endFreq = options?.endFreq ?? 200;
64
+ const volume = options?.volume ?? 0.4;
65
+ const duration = options?.duration ?? 0.2;
66
+
67
+ const out = ctx.createGain();
68
+ out.gain.value = volume;
69
+ out.connect(dest);
70
+
71
+ return {
72
+ play() {
73
+ const now = ctx.currentTime;
74
+ const osc = ctx.createOscillator();
75
+ osc.type = 'sawtooth';
76
+ osc.frequency.setValueAtTime(startFreq, now);
77
+ osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
78
+
79
+ const env = ctx.createGain();
80
+ env.gain.setValueAtTime(1, now);
81
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
82
+
83
+ osc.connect(env);
84
+ env.connect(out);
85
+ osc.start(now);
86
+ osc.stop(now + duration + 0.01);
87
+ },
88
+ dispose() {
89
+ out.disconnect();
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Charge-up tone — ascending pitch, call play() to start and stop() to end. */
95
+ export function chargeUp(
96
+ ctx: AudioContext,
97
+ dest: AudioNode,
98
+ options?: { startFreq?: number; endFreq?: number; volume?: number; duration?: number },
99
+ ) {
100
+ const startFreq = options?.startFreq ?? 100;
101
+ const endFreq = options?.endFreq ?? 800;
102
+ const volume = options?.volume ?? 0.3;
103
+ const duration = options?.duration ?? 2.0;
104
+
105
+ const out = ctx.createGain();
106
+ out.gain.value = 0;
107
+ out.connect(dest);
108
+
109
+ let osc: OscillatorNode | null = null;
110
+
111
+ return {
112
+ play() {
113
+ if (osc) return;
114
+ const now = ctx.currentTime;
115
+ osc = ctx.createOscillator();
116
+ osc.type = 'sawtooth';
117
+ osc.frequency.setValueAtTime(startFreq, now);
118
+ osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
119
+ osc.connect(out);
120
+ osc.start(now);
121
+ out.gain.setValueAtTime(0.001, now);
122
+ out.gain.exponentialRampToValueAtTime(volume, now + 0.05);
123
+ },
124
+ stop() {
125
+ if (!osc) return;
126
+ const now = ctx.currentTime;
127
+ out.gain.setTargetAtTime(0, now, 0.02);
128
+ const ref = osc;
129
+ osc = null;
130
+ setTimeout(() => {
131
+ try {
132
+ ref.stop();
133
+ } catch {
134
+ /* already stopped */
135
+ }
136
+ ref.disconnect();
137
+ }, 100);
138
+ },
139
+ dispose() {
140
+ if (osc) {
141
+ try {
142
+ osc.stop();
143
+ } catch {
144
+ /* already stopped */
145
+ }
146
+ osc.disconnect();
147
+ osc = null;
148
+ }
149
+ out.disconnect();
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,127 @@
1
+ import type { GameLoopConfig } from './types';
2
+
3
+ /**
4
+ * Fixed-timestep game loop with accumulator pattern.
5
+ *
6
+ * Physics/logic run at a fixed rate (default 60 Hz) regardless of display
7
+ * frame rate. Rendering is deliberately fixed-rate too (decided — D1, see
8
+ * `docs/DECISIONS-PENDING.md`): `config.update(fixedDt)` runs once per
9
+ * consumed substep, and per the documented phase order (`PHASE_ORDER` in
10
+ * `core/types.ts`) the `render` phase executes as the last phase of that
11
+ * same call — there is no separate per-real-frame render step and no
12
+ * display-refresh interpolation. A frame whose accumulator produces zero
13
+ * substeps therefore calls `update` zero times and renders zero times; do
14
+ * not reintroduce a render call outside the substep loop below.
15
+ */
16
+ export function createGameLoop(config: GameLoopConfig) {
17
+ const fixedDt = config.fixedTimestep ?? 1 / 60;
18
+ const maxSubSteps = config.maxSubSteps ?? 8;
19
+ // Also the accumulator's hard ceiling (see the spiral-of-death guard below).
20
+ const maxAccumulator = fixedDt * maxSubSteps;
21
+
22
+ let accumulator = 0;
23
+ let lastTime = 0;
24
+ let running = false;
25
+ let rafId = 0;
26
+ let timeScale = 1.0;
27
+
28
+ // Idle throttle (T2.1): a hidden tab stops the loop outright; becoming
29
+ // visible again restarts it with the accumulator clock resynced to "now" —
30
+ // deliberately no catch-up burst for the wall-clock time spent hidden.
31
+ let hiddenPaused = false;
32
+ let visibilityListenerAttached = false;
33
+
34
+ function handleVisibilityChange() {
35
+ if (typeof document === 'undefined') return;
36
+ if (document.hidden) {
37
+ if (running) {
38
+ running = false;
39
+ cancelAnimationFrame(rafId);
40
+ hiddenPaused = true;
41
+ }
42
+ } else if (hiddenPaused) {
43
+ hiddenPaused = false;
44
+ running = true;
45
+ lastTime = performance.now();
46
+ accumulator = 0;
47
+ rafId = requestAnimationFrame(frame);
48
+ }
49
+ }
50
+
51
+ function frame(currentTime: number) {
52
+ if (!running) return;
53
+ rafId = requestAnimationFrame(frame);
54
+
55
+ // Convert to seconds
56
+ const rawDt = (currentTime - lastTime) / 1000;
57
+ lastTime = currentTime;
58
+
59
+ // Clamp large frame gaps (e.g. a slow frame) before scaling by timeScale.
60
+ const dt = Math.min(rawDt, maxAccumulator) * timeScale;
61
+ accumulator += dt;
62
+ // Spiral-of-death guard: bound the accumulator regardless of timeScale
63
+ // (up to the clamped max of 8) or frame-gap size. Time that can't
64
+ // possibly be caught up on is dropped, never carried forward.
65
+ if (accumulator > maxAccumulator) accumulator = maxAccumulator;
66
+
67
+ // Run fixed timestep updates (this is also where rendering happens —
68
+ // see the module doc comment above).
69
+ let steps = 0;
70
+ while (accumulator >= fixedDt && steps < maxSubSteps) {
71
+ config.update(fixedDt);
72
+ accumulator -= fixedDt;
73
+ steps++;
74
+ }
75
+ }
76
+
77
+ return {
78
+ start() {
79
+ if (running) return;
80
+ running = true;
81
+ // A direct start() call can land while the loop is auto-hidden-paused
82
+ // (`hiddenPaused` true, `running` false, no rAF pending). Without this
83
+ // reset, a later visibilitychange-to-visible would see stale
84
+ // `hiddenPaused === true` and spawn a SECOND rAF chain on top of the
85
+ // one this call is about to start (doubled update()/render() overhead
86
+ // until the next stop() — no sim-speed effect, but wasted work).
87
+ hiddenPaused = false;
88
+ lastTime = performance.now();
89
+ accumulator = 0;
90
+ rafId = requestAnimationFrame(frame);
91
+
92
+ if (!visibilityListenerAttached && typeof document !== 'undefined') {
93
+ document.addEventListener('visibilitychange', handleVisibilityChange);
94
+ visibilityListenerAttached = true;
95
+ }
96
+ },
97
+
98
+ stop() {
99
+ running = false;
100
+ hiddenPaused = false;
101
+ cancelAnimationFrame(rafId);
102
+
103
+ if (visibilityListenerAttached && typeof document !== 'undefined') {
104
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
105
+ visibilityListenerAttached = false;
106
+ }
107
+ },
108
+
109
+ get isRunning() {
110
+ return running;
111
+ },
112
+
113
+ set timeScale(value: number) {
114
+ const clamped = Math.min(8, Math.max(0, value));
115
+ if (clamped !== value) {
116
+ console.warn(
117
+ `[game-loop] timeScale ${value} is out of range [0, 8]; clamped to ${clamped}.`,
118
+ );
119
+ }
120
+ timeScale = clamped;
121
+ },
122
+
123
+ get timeScale() {
124
+ return timeScale;
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,298 @@
1
+ import { PHASE_ORDER, type SystemDef, type SystemFn, type SystemPhaseName } from './types';
2
+
3
+ /**
4
+ * Ordered system execution by named phase.
5
+ *
6
+ * Systems are registered with a phase name. When `run(dt)` is called, all
7
+ * phases execute in `PHASE_ORDER` via `runPhase(phase, dt)`. Within a phase,
8
+ * `runPhase` executes three ordered buckets (GAME-ROOT-DESIGN.md §4, T7.1
9
+ * slice 2):
10
+ *
11
+ * 1. **engine** — everything `add()`/`register()`ed at or before the last
12
+ * `markEngineBoundary()` call, or EVERYTHING if no boundary has ever
13
+ * been marked (the world2d-silo case: it never calls
14
+ * `markEngineBoundary`, so its systems are all "engine").
15
+ * 2. **componentTick** — the single per-phase slot set via
16
+ * `setComponentTick(phase, fn)`. Component ticks are engine
17
+ * infrastructure: they always run after every engine system and before
18
+ * every game system in that phase, REGARDLESS of when
19
+ * `setComponentTick` was called relative to other `add()` calls. This
20
+ * is what makes "engine systems run before component ticks" a
21
+ * structural guarantee rather than a registration-order convention
22
+ * (see `component-manager.ts`, which registers its tick this way).
23
+ * 3. **game** — everything `add()`/`register()`ed AFTER the last
24
+ * `markEngineBoundary()` call.
25
+ *
26
+ * Within each bucket, systems run in registration order.
27
+ */
28
+ export function createSystemRunner() {
29
+ const systems = new Map<SystemPhaseName, SystemFn[]>();
30
+ const registered: SystemDef[] = [];
31
+ const componentTicks = new Map<SystemPhaseName, SystemFn | null>();
32
+
33
+ // Initialize all phases with empty arrays / no component tick.
34
+ for (const phase of PHASE_ORDER) {
35
+ systems.set(phase, []);
36
+ componentTicks.set(phase, null);
37
+ }
38
+
39
+ // Membership bookkeeping for `markEngineBoundary()` (T7.2 review fix —
40
+ // replaces the old length/index-snapshot bookkeeping, which broke if a
41
+ // pre-boundary "engine" entry was ever removed: shrinking the list would
42
+ // shift a later "game" entry underneath the recorded length, misclassifying
43
+ // it as engine). `engineFns`/`engineRegistered` instead record WHICH
44
+ // specific function/system was present at the last `markEngineBoundary()`
45
+ // call — membership survives arbitrary removal of any other entry,
46
+ // regardless of position. `boundaryMarked` is false until the first mark
47
+ // (mirrors the old `boundary === null`: everything is "engine" pre-mark —
48
+ // the world2d-silo case, which never marks a boundary). Component-tick
49
+ // slots (`setComponentTick`) are NOT part of this bookkeeping at all — they
50
+ // are engine infrastructure and survive `removeAllNonEngine()`
51
+ // unconditionally.
52
+ let boundaryMarked = false;
53
+ const engineFns = new Map<SystemPhaseName, Set<SystemFn>>();
54
+ for (const phase of PHASE_ORDER) engineFns.set(phase, new Set());
55
+ const engineRegistered = new Set<SystemDef>();
56
+
57
+ /** Run one system function, isolating a throw so it never wedges the frame. */
58
+ function runOne(fn: SystemFn, phase: SystemPhaseName, dt: number): void {
59
+ try {
60
+ fn(dt);
61
+ } catch (err) {
62
+ const label = fn.name ? `"${fn.name}"` : '(anonymous)';
63
+ console.error(`[system-runner] system ${label} in phase "${phase}" threw:`, err);
64
+ }
65
+ }
66
+
67
+ return {
68
+ /**
69
+ * Register a bare system function in a specific phase.
70
+ * Systems within the same phase run in the order they were added.
71
+ */
72
+ add(phase: SystemPhaseName, fn: SystemFn) {
73
+ const list = systems.get(phase);
74
+ if (!list) {
75
+ throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
76
+ }
77
+ list.push(fn);
78
+ },
79
+
80
+ /**
81
+ * Remove a bare system function from a phase. Also drops it from the
82
+ * engine-membership set (if present) — the fix for the length-based
83
+ * bookkeeping bug: removing this has no effect on how any OTHER entry
84
+ * (before or after it) is classified, since classification is now
85
+ * per-function membership, not position.
86
+ */
87
+ remove(phase: SystemPhaseName, fn: SystemFn) {
88
+ const list = systems.get(phase);
89
+ if (!list) return;
90
+ const idx = list.indexOf(fn);
91
+ if (idx !== -1) list.splice(idx, 1);
92
+ engineFns.get(phase)?.delete(fn);
93
+ },
94
+
95
+ /**
96
+ * Register a lifecycle system. Adds its update to the correct phase.
97
+ * Call runInit() after all systems are registered to invoke init hooks.
98
+ */
99
+ register(system: SystemDef) {
100
+ const list = systems.get(system.phase);
101
+ if (!list) {
102
+ throw new Error(`Unknown phase: ${system.phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
103
+ }
104
+ list.push(system.update);
105
+ registered.push(system);
106
+ },
107
+
108
+ /**
109
+ * Unregister a lifecycle system. Removes its update and calls dispose.
110
+ */
111
+ unregister(system: SystemDef) {
112
+ const list = systems.get(system.phase);
113
+ if (list) {
114
+ const idx = list.indexOf(system.update);
115
+ if (idx !== -1) list.splice(idx, 1);
116
+ }
117
+ engineFns.get(system.phase)?.delete(system.update);
118
+ const regIdx = registered.indexOf(system);
119
+ if (regIdx !== -1) registered.splice(regIdx, 1);
120
+ engineRegistered.delete(system);
121
+ system.dispose?.();
122
+ },
123
+
124
+ /**
125
+ * Register the ONE component-tick function for a phase (T7.1 slice 2).
126
+ * Component ticks are engine infrastructure, not a bare `add()`ed
127
+ * system: they always execute after every engine-bucket system and
128
+ * before every game-bucket system in that phase (see `runPhase`),
129
+ * they are never touched by `removeAllNonEngine()`, and they do not
130
+ * count toward `markEngineBoundary()`'s bookkeeping. Throws if a tick
131
+ * is already set for this phase — at most one component manager may
132
+ * own a phase's tick slot.
133
+ */
134
+ setComponentTick(phase: SystemPhaseName, fn: SystemFn) {
135
+ if (!componentTicks.has(phase)) {
136
+ throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
137
+ }
138
+ if (componentTicks.get(phase)) {
139
+ throw new Error(
140
+ `[system-runner] setComponentTick: phase "${phase}" already has a component tick registered`,
141
+ );
142
+ }
143
+ componentTicks.set(phase, fn);
144
+ },
145
+
146
+ /**
147
+ * Call init() on all registered lifecycle systems, in phase order.
148
+ * Call once after scene load, before the first game loop tick.
149
+ */
150
+ runInit() {
151
+ const sorted = [...registered].sort(
152
+ (a, b) => PHASE_ORDER.indexOf(a.phase) - PHASE_ORDER.indexOf(b.phase),
153
+ );
154
+ for (const sys of sorted) {
155
+ sys.init?.();
156
+ }
157
+ },
158
+
159
+ /**
160
+ * Call dispose() on all registered lifecycle systems and remove them.
161
+ */
162
+ runDispose() {
163
+ for (const sys of registered) {
164
+ const list = systems.get(sys.phase);
165
+ if (list) {
166
+ const idx = list.indexOf(sys.update);
167
+ if (idx !== -1) list.splice(idx, 1);
168
+ }
169
+ sys.dispose?.();
170
+ }
171
+ registered.length = 0;
172
+ },
173
+
174
+ /**
175
+ * Run ONE phase's three ordered buckets — engine, componentTick, game
176
+ * (see the module doc comment). Each system call is isolated: a
177
+ * throwing system is loudly logged (never swallowed) but does not stop
178
+ * its siblings in the same bucket, a later bucket in this phase, or a
179
+ * later phase, from running this frame.
180
+ *
181
+ * Public — the Game root's frame executor (`runtime/game.ts`) calls this
182
+ * directly per (phase, world); `run(dt)` below is just a loop over it,
183
+ * preserved for the world2d silo and any direct caller.
184
+ */
185
+ runPhase(phase: SystemPhaseName, dt: number) {
186
+ const list = systems.get(phase);
187
+ if (!list) {
188
+ throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
189
+ }
190
+ // Partition by MEMBERSHIP, not position: everything in this phase's
191
+ // `engineFns` set (or, if no boundary has ever been marked, everything —
192
+ // the world2d-silo case) runs immediately, in list order, as it's
193
+ // encountered; everything else is queued into `gameFns` (also in list
194
+ // order) and run after the component tick. Because classification is
195
+ // per-function rather than "index < some remembered length", removing
196
+ // ANY entry via `remove()` — including a pre-boundary "engine" one —
197
+ // cannot shift a later game system into the engine bucket.
198
+ const engineSet = engineFns.get(phase)!;
199
+ const gameFns: SystemFn[] = [];
200
+ for (const fn of list) {
201
+ if (boundaryMarked && !engineSet.has(fn)) {
202
+ gameFns.push(fn);
203
+ } else {
204
+ runOne(fn, phase, dt);
205
+ }
206
+ }
207
+
208
+ const tick = componentTicks.get(phase);
209
+ if (tick) runOne(tick, phase, dt);
210
+
211
+ for (const fn of gameFns) {
212
+ runOne(fn, phase, dt);
213
+ }
214
+ },
215
+
216
+ /**
217
+ * Run all phases in order, via `runPhase`.
218
+ */
219
+ run(dt: number) {
220
+ for (const phase of PHASE_ORDER) {
221
+ this.runPhase(phase, dt);
222
+ }
223
+ },
224
+
225
+ /**
226
+ * Mark everything registered so far (via `add()`/`register()`, in every
227
+ * phase) as "engine" — infrastructure that must survive a warm restart.
228
+ * Call once, right after mount finishes wiring engine-level systems and
229
+ * BEFORE any game (`setup()`/scene load) registers its own. `hotReload`
230
+ * calls `removeAllNonEngine()` on every restart, which only ever removes
231
+ * what was added after this mark. Component-tick slots (`setComponentTick`)
232
+ * are unaffected — they are never part of this boundary.
233
+ */
234
+ markEngineBoundary() {
235
+ for (const phase of PHASE_ORDER) {
236
+ const set = engineFns.get(phase)!;
237
+ for (const fn of systems.get(phase)!) set.add(fn);
238
+ }
239
+ for (const sys of registered) engineRegistered.add(sys);
240
+ boundaryMarked = true;
241
+ },
242
+
243
+ /**
244
+ * Bulk-remove every system registered after the last `markEngineBoundary()`
245
+ * call — both bare `add()`ed functions and lifecycle `register()`ed systems
246
+ * (the latter get `dispose()`d, mirroring `unregister()`). A no-op if no
247
+ * boundary has been marked. Idempotent: calling it again with nothing new
248
+ * registered since is a safe no-op. Component-tick slots are NEVER cleared
249
+ * by this — they are engine infrastructure, not game content.
250
+ *
251
+ * This is what makes a warm restart (`hotReload`) safe to call N times
252
+ * without accumulating duplicate systems/listeners — each restart's
253
+ * outgoing game systems are fully removed before the new one registers its
254
+ * own, while the engine systems (input/physics/render/the ComponentManager
255
+ * tick/...) registered before the boundary are never touched.
256
+ */
257
+ removeAllNonEngine() {
258
+ if (!boundaryMarked) return;
259
+ // Membership-based, same reasoning as runPhase: partition by whether
260
+ // each entry is in the engine set, not by a remembered length/index.
261
+ const keptRegistered = registered.filter((sys) => engineRegistered.has(sys));
262
+ const removedRegistered = registered.filter((sys) => !engineRegistered.has(sys));
263
+ registered.length = 0;
264
+ registered.push(...keptRegistered);
265
+ for (const sys of removedRegistered) {
266
+ sys.dispose?.();
267
+ }
268
+
269
+ for (const phase of PHASE_ORDER) {
270
+ const list = systems.get(phase)!;
271
+ const engineSet = engineFns.get(phase)!;
272
+ const kept = list.filter((fn) => engineSet.has(fn));
273
+ list.length = 0;
274
+ list.push(...kept);
275
+ }
276
+ },
277
+
278
+ /**
279
+ * Total system-function count, optionally scoped to one phase. Includes
280
+ * component-tick slots (a set slot counts as 1 per phase it occupies).
281
+ * Test/introspection helper — used to assert a warm restart doesn't
282
+ * accumulate systems (see `removeAllNonEngine`).
283
+ */
284
+ count(phase?: SystemPhaseName): number {
285
+ if (phase) {
286
+ return (systems.get(phase)?.length ?? 0) + (componentTicks.get(phase) ? 1 : 0);
287
+ }
288
+ let total = 0;
289
+ for (const [p, list] of systems) {
290
+ total += list.length;
291
+ if (componentTicks.get(p)) total += 1;
292
+ }
293
+ return total;
294
+ },
295
+ };
296
+ }
297
+
298
+ export type SystemRunner = ReturnType<typeof createSystemRunner>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * System execution phases, in order.
3
+ * Each frame, systems run in this exact sequence.
4
+ */
5
+ export const SystemPhase = {
6
+ INPUT: 'input',
7
+ PRE_PHYSICS: 'prePhysics',
8
+ PHYSICS: 'physics',
9
+ POST_PHYSICS: 'postPhysics',
10
+ GAME_LOGIC: 'gameLogic',
11
+ ANIMATION: 'animation',
12
+ PRE_RENDER: 'preRender',
13
+ RENDER: 'render',
14
+ } as const;
15
+
16
+ export type SystemPhaseName = (typeof SystemPhase)[keyof typeof SystemPhase];
17
+
18
+ /** Order of phase execution */
19
+ export const PHASE_ORDER: SystemPhaseName[] = [
20
+ SystemPhase.INPUT,
21
+ SystemPhase.PRE_PHYSICS,
22
+ SystemPhase.PHYSICS,
23
+ SystemPhase.POST_PHYSICS,
24
+ SystemPhase.GAME_LOGIC,
25
+ SystemPhase.ANIMATION,
26
+ SystemPhase.PRE_RENDER,
27
+ SystemPhase.RENDER,
28
+ ];
29
+
30
+ /** A system is just a function that takes delta time */
31
+ export type SystemFn = (dt: number) => void;
32
+
33
+ /** A lifecycle system with init/update/dispose hooks. */
34
+ export interface SystemDef {
35
+ /** Which phase the update function runs in */
36
+ phase: SystemPhaseName;
37
+ /** One-time setup after all entities are spawned, before the first frame */
38
+ init?(): void;
39
+ /** Per-frame update */
40
+ update: SystemFn;
41
+ /** Cleanup when the system is removed or the scene unloads */
42
+ dispose?(): void;
43
+ }
44
+
45
+ export interface GameLoopConfig {
46
+ /** Fixed timestep in seconds (default: 1/60) */
47
+ fixedTimestep?: number;
48
+ /** Max physics substeps per frame to prevent spiral of death (default: 8) */
49
+ maxSubSteps?: number;
50
+ /**
51
+ * Called once per consumed fixed substep. Rendering (the `render` phase,
52
+ * last in `PHASE_ORDER`) runs *inside* this call — rendering is
53
+ * deliberately fixed-rate (decided — D1, `docs/DECISIONS-PENDING.md`),
54
+ * not a separate per-real-frame step. A real frame whose accumulator
55
+ * produces zero substeps calls `update` zero times and renders zero times.
56
+ */
57
+ update: (dt: number) => void;
58
+ }