@waica/engine 0.13.0 → 0.14.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.
@@ -1,3 +1,4 @@
1
+ import { SIMULATION_TIME_EPSILON } from '../fixed-step.js';
1
2
  /**
2
3
  * Advances a clip through time. Pure logic (no three, no DOM) so it can
3
4
  * be tested deterministically.
@@ -16,7 +17,11 @@ export class ClipPlayer {
16
17
  /** Advances the clock and returns the sheet frame to show. */
17
18
  advance(dt) {
18
19
  this.t += dt;
19
- const idx = Math.floor(this.t * this.fps);
20
+ // this.t is a sum of SIMULATION_STEP-sized dts, which float error can
21
+ // leave a hair under an exact multiple of a frame's duration; without
22
+ // the epsilon, floor(t * fps) holds some frames one step short and
23
+ // others one step long instead of a flat, even count per frame.
24
+ const idx = Math.floor((this.t + SIMULATION_TIME_EPSILON) * this.fps);
20
25
  const n = this.frames.length;
21
26
  const clamped = this.loop ? idx % n : Math.min(idx, n - 1);
22
27
  return this.frames[clamped] ?? 0;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Milliseconds per display frame at `hz`, for driving `Game`'s real
3
+ * animation-loop callback in a test. No epsilon pad: frame-rate snapping
4
+ * (`snapElapsedToStep`) is what makes a measured duration this close to a
5
+ * whole number of Simulation Steps count as exactly that many, not a float
6
+ * nudge chosen to dodge the exact boundary — `1000 / 60 / 1000 === 1 / 60`
7
+ * bit-for-bit already, before snapping even applies.
8
+ */
9
+ export declare const frameMs: (hz: number) => number;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Milliseconds per display frame at `hz`, for driving `Game`'s real
3
+ * animation-loop callback in a test. No epsilon pad: frame-rate snapping
4
+ * (`snapElapsedToStep`) is what makes a measured duration this close to a
5
+ * whole number of Simulation Steps count as exactly that many, not a float
6
+ * nudge chosen to dodge the exact boundary — `1000 / 60 / 1000 === 1 / 60`
7
+ * bit-for-bit already, before snapping even applies.
8
+ */
9
+ export const frameMs = (hz) => 1000 / hz;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The Simulation Step (ADR 0014): the fixed slice of game time by which the
3
+ * engine advances every component update, however often the display
4
+ * refreshes. An engine constant, not a project setting — the archetypes'
5
+ * feel is tuned against it, and the Runtime Bridge's `step` means exactly
6
+ * one of these.
7
+ */
8
+ export declare const SIMULATION_STEP: number;
9
+ /**
10
+ * The most steps one render frame may run. Beyond it the elapsed time is
11
+ * dropped, never repaid: a machine that cannot keep up falls behind the
12
+ * wall clock instead of spiralling into ever-longer frames. Six steps is
13
+ * 100 ms, the same ceiling the old per-frame clamp imposed.
14
+ */
15
+ export declare const MAX_STEPS_PER_FRAME = 6;
16
+ /**
17
+ * Floating-point slack for comparing a value built by summing many
18
+ * SIMULATION_STEP-sized deltas (a state's `elapsed`, a countdown, a clip's
19
+ * playback clock) against a target duration. Each addition can leave the
20
+ * sum a hair under the mathematically exact value — 15 additions of 1/60
21
+ * give 0.24999999999999997, not 0.25 — by an error on the order of 1e-15,
22
+ * many orders of magnitude below this. Large enough to call a step-multiple
23
+ * duration exact, far too small to ever mistake a genuinely later step for
24
+ * an earlier one.
25
+ */
26
+ export declare const SIMULATION_TIME_EPSILON = 1e-9;
27
+ /**
28
+ * Cap on same-tick, chained processing that could otherwise spin forever
29
+ * on a degenerate cycle: StateMachine settling a chain of transitions
30
+ * within one `onUpdate` (land → idle → run…) and Game draining a chain of
31
+ * `loadSceneByName` calls queued from a scene's own `onReady` within one
32
+ * frame. Shared so the two call sites' caps stay in lockstep instead of
33
+ * matching only by coincidence; eight is comfortably more hops than any
34
+ * real content chains in a single update.
35
+ */
36
+ export declare const MAX_CHAINED_HOPS = 8;
37
+ export interface SimulationSteps {
38
+ /** Whole steps this frame runs, 0 through MAX_STEPS_PER_FRAME. */
39
+ steps: number;
40
+ /** Seconds carried into the next frame; 0 whenever the cap was hit. */
41
+ remainder: number;
42
+ }
43
+ /**
44
+ * Pure accumulator: from the time retained after the last frame and the
45
+ * seconds elapsed since it, how many whole steps to run now and what to
46
+ * keep. Hitting the cap discards the whole remainder (CA-2). A non-finite
47
+ * or negative `elapsed` (a NaN timestamp delta, a backwards clock) is
48
+ * treated as zero rather than poisoning the remainder or yielding negative
49
+ * steps — hardening against inputs a real rAF timestamp never produces.
50
+ */
51
+ export declare function consumeSimulationSteps(remainder: number, elapsed: number): SimulationSteps;
52
+ /**
53
+ * How far a measured frame duration may sit from a whole number of
54
+ * Simulation Steps and still count as exactly that many (round 2
55
+ * correctness fix). Sized to absorb not just the sub-millisecond jitter a
56
+ * real 60 Hz `requestAnimationFrame` shows on Chromium — timestamp
57
+ * coarsening and float noise there put it at roughly 0.1-0.3 ms — but also
58
+ * the coarser 1-2 ms `requestAnimationFrame` timestamp rounding Firefox
59
+ * and Safari apply (their privacy/fingerprinting mitigation), without ever
60
+ * mistaking a genuine partial step for one of these snaps.
61
+ */
62
+ export declare const STEP_SNAP_TOLERANCE = 0.0025;
63
+ export interface SnapResult {
64
+ /** The elapsed time to feed the accumulator: snapped, and possibly resynced. */
65
+ elapsed: number;
66
+ /** Time discarded by snapping so far, still owed to (or by) the wall clock. */
67
+ residual: number;
68
+ }
69
+ /**
70
+ * Frame-rate snapping (ADR 0014, round 2 correctness): a measured frame
71
+ * duration that lands within STEP_SNAP_TOLERANCE of an exact multiple of
72
+ * SIMULATION_STEP is treated as exactly that multiple. Without this, a
73
+ * display refreshing at exactly 60.00 Hz can measure e.g. 16.6666 ms
74
+ * instead of the true 16.6667 ms — `consumeSimulationSteps` then floors the
75
+ * whole-steps count to 0 that frame and 2 the next, a routine 0/2-step
76
+ * judder at the one refresh rate ADR 0014 calls exact; on Firefox and
77
+ * Safari the same judder shows up permanently, every frame, because their
78
+ * coarser timestamp rounding never lands as close to the exact multiple as
79
+ * a 0.25 ms tolerance required. Applied to the raw per-frame measurement
80
+ * before it ever reaches the accumulator, so `consumeSimulationSteps`
81
+ * itself — and CA-2's 0.034 s / 0.0999 s / 0.005 s examples, each well
82
+ * outside the tolerance — are untouched.
83
+ *
84
+ * Every snap discards `elapsed - target`, which is carried forward in
85
+ * `residual` (round 3 correctness) instead of vanishing: a display a hair
86
+ * off 60.00 Hz — 59.94 Hz, the common NTSC-derived panel rate, discards
87
+ * ~0.017 ms every frame — would otherwise drift from the wall clock
88
+ * without bound (measured: -3.6 s/h at 59.94 Hz). Once the accumulated
89
+ * residual reaches a whole Simulation Step, one step is repaid into
90
+ * `elapsed` right away and subtracted back out of the residual, so the
91
+ * simulation is never more than about one step away from the wall clock.
92
+ */
93
+ export declare function snapElapsedToStep(elapsed: number, residual: number): SnapResult;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The Simulation Step (ADR 0014): the fixed slice of game time by which the
3
+ * engine advances every component update, however often the display
4
+ * refreshes. An engine constant, not a project setting — the archetypes'
5
+ * feel is tuned against it, and the Runtime Bridge's `step` means exactly
6
+ * one of these.
7
+ */
8
+ export const SIMULATION_STEP = 1 / 60;
9
+ /**
10
+ * The most steps one render frame may run. Beyond it the elapsed time is
11
+ * dropped, never repaid: a machine that cannot keep up falls behind the
12
+ * wall clock instead of spiralling into ever-longer frames. Six steps is
13
+ * 100 ms, the same ceiling the old per-frame clamp imposed.
14
+ */
15
+ export const MAX_STEPS_PER_FRAME = 6;
16
+ /**
17
+ * Floating-point slack for comparing a value built by summing many
18
+ * SIMULATION_STEP-sized deltas (a state's `elapsed`, a countdown, a clip's
19
+ * playback clock) against a target duration. Each addition can leave the
20
+ * sum a hair under the mathematically exact value — 15 additions of 1/60
21
+ * give 0.24999999999999997, not 0.25 — by an error on the order of 1e-15,
22
+ * many orders of magnitude below this. Large enough to call a step-multiple
23
+ * duration exact, far too small to ever mistake a genuinely later step for
24
+ * an earlier one.
25
+ */
26
+ export const SIMULATION_TIME_EPSILON = 1e-9;
27
+ /**
28
+ * Cap on same-tick, chained processing that could otherwise spin forever
29
+ * on a degenerate cycle: StateMachine settling a chain of transitions
30
+ * within one `onUpdate` (land → idle → run…) and Game draining a chain of
31
+ * `loadSceneByName` calls queued from a scene's own `onReady` within one
32
+ * frame. Shared so the two call sites' caps stay in lockstep instead of
33
+ * matching only by coincidence; eight is comfortably more hops than any
34
+ * real content chains in a single update.
35
+ */
36
+ export const MAX_CHAINED_HOPS = 8;
37
+ /**
38
+ * Pure accumulator: from the time retained after the last frame and the
39
+ * seconds elapsed since it, how many whole steps to run now and what to
40
+ * keep. Hitting the cap discards the whole remainder (CA-2). A non-finite
41
+ * or negative `elapsed` (a NaN timestamp delta, a backwards clock) is
42
+ * treated as zero rather than poisoning the remainder or yielding negative
43
+ * steps — hardening against inputs a real rAF timestamp never produces.
44
+ */
45
+ export function consumeSimulationSteps(remainder, elapsed) {
46
+ const safeElapsed = Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
47
+ const available = remainder + safeElapsed;
48
+ const whole = Math.floor(available / SIMULATION_STEP);
49
+ if (whole >= MAX_STEPS_PER_FRAME)
50
+ return { steps: MAX_STEPS_PER_FRAME, remainder: 0 };
51
+ return { steps: whole, remainder: available - whole * SIMULATION_STEP };
52
+ }
53
+ /**
54
+ * How far a measured frame duration may sit from a whole number of
55
+ * Simulation Steps and still count as exactly that many (round 2
56
+ * correctness fix). Sized to absorb not just the sub-millisecond jitter a
57
+ * real 60 Hz `requestAnimationFrame` shows on Chromium — timestamp
58
+ * coarsening and float noise there put it at roughly 0.1-0.3 ms — but also
59
+ * the coarser 1-2 ms `requestAnimationFrame` timestamp rounding Firefox
60
+ * and Safari apply (their privacy/fingerprinting mitigation), without ever
61
+ * mistaking a genuine partial step for one of these snaps.
62
+ */
63
+ export const STEP_SNAP_TOLERANCE = 0.0025; // seconds (2.5 ms)
64
+ /**
65
+ * Snapping considers only these small step counts: a display sitting
66
+ * exactly on one of the first few Simulation Step boundaries (60 Hz → 1,
67
+ * 30 Hz → 2, and so on) is the case timestamp jitter can push a hair off:
68
+ * higher counts already mean a hitch, where a fraction of a millisecond
69
+ * doesn't matter and forcing a snap would be presumptuous.
70
+ */
71
+ const SNAPPABLE_STEPS = 4;
72
+ /**
73
+ * Frame-rate snapping (ADR 0014, round 2 correctness): a measured frame
74
+ * duration that lands within STEP_SNAP_TOLERANCE of an exact multiple of
75
+ * SIMULATION_STEP is treated as exactly that multiple. Without this, a
76
+ * display refreshing at exactly 60.00 Hz can measure e.g. 16.6666 ms
77
+ * instead of the true 16.6667 ms — `consumeSimulationSteps` then floors the
78
+ * whole-steps count to 0 that frame and 2 the next, a routine 0/2-step
79
+ * judder at the one refresh rate ADR 0014 calls exact; on Firefox and
80
+ * Safari the same judder shows up permanently, every frame, because their
81
+ * coarser timestamp rounding never lands as close to the exact multiple as
82
+ * a 0.25 ms tolerance required. Applied to the raw per-frame measurement
83
+ * before it ever reaches the accumulator, so `consumeSimulationSteps`
84
+ * itself — and CA-2's 0.034 s / 0.0999 s / 0.005 s examples, each well
85
+ * outside the tolerance — are untouched.
86
+ *
87
+ * Every snap discards `elapsed - target`, which is carried forward in
88
+ * `residual` (round 3 correctness) instead of vanishing: a display a hair
89
+ * off 60.00 Hz — 59.94 Hz, the common NTSC-derived panel rate, discards
90
+ * ~0.017 ms every frame — would otherwise drift from the wall clock
91
+ * without bound (measured: -3.6 s/h at 59.94 Hz). Once the accumulated
92
+ * residual reaches a whole Simulation Step, one step is repaid into
93
+ * `elapsed` right away and subtracted back out of the residual, so the
94
+ * simulation is never more than about one step away from the wall clock.
95
+ */
96
+ export function snapElapsedToStep(elapsed, residual) {
97
+ for (let steps = 1; steps <= SNAPPABLE_STEPS; steps += 1) {
98
+ const target = steps * SIMULATION_STEP;
99
+ if (Math.abs(elapsed - target) < STEP_SNAP_TOLERANCE) {
100
+ const pending = residual + (elapsed - target);
101
+ if (Math.abs(pending) >= SIMULATION_STEP) {
102
+ const repaid = Math.sign(pending) * SIMULATION_STEP;
103
+ return { elapsed: target + repaid, residual: pending - repaid };
104
+ }
105
+ return { elapsed: target, residual: pending };
106
+ }
107
+ }
108
+ return { elapsed, residual };
109
+ }
package/dist/game.d.ts CHANGED
@@ -77,6 +77,14 @@ export declare class Game {
77
77
  private readonly resizeObserver;
78
78
  private readonly updateFns;
79
79
  private readonly invalidUpdateCompositions;
80
+ /**
81
+ * componentUpdateSchedule's result for the last composition signature seen
82
+ * per entity: the resolve (Tarjan SCC + Kahn sort) it's built from is pure
83
+ * for a fixed composition, so a signature match skips it entirely. Only
84
+ * ever holds a successful resolution — an invalid composition is never
85
+ * cached, since invalidUpdateCompositions already dedupes its console.error.
86
+ */
87
+ private readonly updateScheduleCache;
80
88
  private readonly resolution;
81
89
  /** The constructor's viewHeight — unloadScene() restores it. */
82
90
  private readonly baseViewHeight;
@@ -84,7 +92,12 @@ export declare class Game {
84
92
  private sceneCamera;
85
93
  private renderSort;
86
94
  private sceneProjection;
95
+ /** Timestamp of the last animation frame; null until the loop's first frame seeds it. */
87
96
  private lastTime;
97
+ /** Seconds of elapsed time not yet worth a whole Simulation Step (ADR 0014). */
98
+ private stepRemainder;
99
+ /** Seconds discarded by frame-rate snapping, not yet repaid (round 3 correctness). */
100
+ private snapResidual;
88
101
  private runtimeBridge;
89
102
  /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
90
103
  private sceneCatalog;
@@ -134,7 +147,12 @@ export declare class Game {
134
147
  loadParams(url: string): Promise<void>;
135
148
  /** Applies persisted overrides to a freshly added component. */
136
149
  applyParamOverrides(entity: Entity, component: Component): void;
137
- /** Registers a function that runs once per frame. Returns the unsubscribe. */
150
+ /**
151
+ * Registers a function that runs once per Simulation Step, with the step
152
+ * as its dt (ADR 0014). While the Game is not simulating (the editor's edit
153
+ * mode) it runs once per render frame instead, so a host can keep drawing
154
+ * its overlays. Returns the unsubscribe.
155
+ */
138
156
  onUpdate(fn: UpdateFn): () => void;
139
157
  /**
140
158
  * Adopts a scene's render block. Called by loadScene; without a block the
@@ -157,9 +175,46 @@ export declare class Game {
157
175
  setViewHeight(value: number): void;
158
176
  /** Shuts the game down completely (loop, input, GPU). */
159
177
  dispose(): void;
178
+ /**
179
+ * Real-time playback for the Runtime Bridge: the same clock-driven loop
180
+ * as start(), sharing the accumulator, with `onStep` told after every
181
+ * Simulation Step so the bridge counts frames exactly as paused stepping
182
+ * does (CA-7). No wall-clock catch-up: the first frame only seeds the
183
+ * clock (CA-3).
184
+ */
160
185
  private resumeRuntime;
186
+ /** Forgets the clock and any partial step, so the next frame runs no burst. */
187
+ private resetClock;
188
+ /**
189
+ * One animation frame (ADR 0014): the elapsed wall-clock time joins the
190
+ * retained remainder, and as many whole Simulation Steps as it holds run
191
+ * — capped, with the excess dropped, so a hitch can neither spiral nor
192
+ * play in slow motion. Not simulating: no time accrues at all. The
193
+ * measured duration is frame-rate-snapped first (round 2 correctness) so
194
+ * sub-millisecond timestamp jitter at an exact cadence like 60 Hz can't
195
+ * flip the whole-steps floor and judder 0/2/0/2.
196
+ */
161
197
  private tick;
198
+ /**
199
+ * Runs `steps` Simulation Steps back to back, then the once-per-frame
200
+ * tail: audio activity and placements, the UI overlay and the render
201
+ * (CA-5). A queued scene swap flushes at the very start of the frame —
202
+ * loadSceneByName's contract — and again before every step after the
203
+ * first (CA-4), so two steps in one frame never see the same press
204
+ * twice or the outgoing scene once too often; a frame that runs zero
205
+ * steps (round 2 correctness) still flushes, so it never renders/
206
+ * audio-places the outgoing scene one frame longer than it should.
207
+ */
162
208
  private runFrame;
209
+ /**
210
+ * One Simulation Step: the Component Update Schedule (ADR 0004) in full,
211
+ * collisions, the scene camera and the host's callbacks, every one of
212
+ * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
213
+ */
214
+ private simulateStep;
215
+ /** Closes a step (real or the non-simulating stand-in): host callbacks, then the input frame. */
216
+ private finishStep;
217
+ private runHostUpdates;
163
218
  private flushPendingSceneLoad;
164
219
  private unregisterRuntimeBridge;
165
220
  /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
package/dist/game.js CHANGED
@@ -6,6 +6,7 @@ import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
6
6
  import { Hitbox } from './components/hitbox.js';
7
7
  import { Entity } from './entity.js';
8
8
  import { Emitter } from './events.js';
9
+ import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
9
10
  import { Input } from './input.js';
10
11
  import { Pointer } from './pointer.js';
11
12
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
@@ -44,6 +45,14 @@ export class Game {
44
45
  resizeObserver;
45
46
  updateFns = new Set();
46
47
  invalidUpdateCompositions = new WeakMap();
48
+ /**
49
+ * componentUpdateSchedule's result for the last composition signature seen
50
+ * per entity: the resolve (Tarjan SCC + Kahn sort) it's built from is pure
51
+ * for a fixed composition, so a signature match skips it entirely. Only
52
+ * ever holds a successful resolution — an invalid composition is never
53
+ * cached, since invalidUpdateCompositions already dedupes its console.error.
54
+ */
55
+ updateScheduleCache = new WeakMap();
47
56
  resolution;
48
57
  /** The constructor's viewHeight — unloadScene() restores it. */
49
58
  baseViewHeight;
@@ -51,7 +60,12 @@ export class Game {
51
60
  sceneCamera = null;
52
61
  renderSort = null;
53
62
  sceneProjection = null;
54
- lastTime = 0;
63
+ /** Timestamp of the last animation frame; null until the loop's first frame seeds it. */
64
+ lastTime = null;
65
+ /** Seconds of elapsed time not yet worth a whole Simulation Step (ADR 0014). */
66
+ stepRemainder = 0;
67
+ /** Seconds discarded by frame-rate snapping, not yet repaid (round 3 correctness). */
68
+ snapResidual = 0;
55
69
  runtimeBridge = null;
56
70
  /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
57
71
  sceneCatalog = null;
@@ -217,7 +231,12 @@ export class Game {
217
231
  if (override)
218
232
  Object.assign(component, override);
219
233
  }
220
- /** Registers a function that runs once per frame. Returns the unsubscribe. */
234
+ /**
235
+ * Registers a function that runs once per Simulation Step, with the step
236
+ * as its dt (ADR 0014). While the Game is not simulating (the editor's edit
237
+ * mode) it runs once per render frame instead, so a host can keep drawing
238
+ * its overlays. Returns the unsubscribe.
239
+ */
221
240
  onUpdate(fn) {
222
241
  this.updateFns.add(fn);
223
242
  return () => this.updateFns.delete(fn);
@@ -265,8 +284,8 @@ export class Game {
265
284
  if (!this.runtimeBridge) {
266
285
  const inspector = new RuntimeInspector(this);
267
286
  this.runtimeBridge = new EngineRuntimeBridge(this.renderer.domElement, activation, {
268
- step: (dt) => this.runFrame(dt),
269
- resume: (frame) => this.resumeRuntime(frame),
287
+ step: (onStep) => this.runFrame(1, onStep),
288
+ resume: (onStep) => this.resumeRuntime(onStep),
270
289
  pause: () => this.stop(),
271
290
  injectAction: (action, operation) => this.input.injectAction(action, operation),
272
291
  availableActions: () => this.input.availableActions(),
@@ -285,6 +304,7 @@ export class Game {
285
304
  this.renderSurface();
286
305
  return;
287
306
  }
307
+ this.resetClock();
288
308
  this.renderer.setAnimationLoop((time) => this.tick(time));
289
309
  }
290
310
  stop() {
@@ -321,60 +341,129 @@ export class Game {
321
341
  entity.destroy();
322
342
  this.renderer.dispose();
323
343
  }
324
- resumeRuntime(frame) {
325
- let previousTime = null;
326
- this.renderer.setAnimationLoop((time) => {
327
- const dt = previousTime === null ? 0 : Math.min((time - previousTime) / 1000, 0.1);
328
- previousTime = time;
329
- frame(dt);
330
- });
344
+ /**
345
+ * Real-time playback for the Runtime Bridge: the same clock-driven loop
346
+ * as start(), sharing the accumulator, with `onStep` told after every
347
+ * Simulation Step so the bridge counts frames exactly as paused stepping
348
+ * does (CA-7). No wall-clock catch-up: the first frame only seeds the
349
+ * clock (CA-3).
350
+ */
351
+ resumeRuntime(onStep) {
352
+ this.resetClock();
353
+ this.renderer.setAnimationLoop((time) => this.tick(time, onStep));
331
354
  }
332
- tick(time) {
333
- // Clamp dt: switching tabs or pausing doesn't fast-forward the simulation.
334
- const dt = Math.min((time - this.lastTime) / 1000, 0.1);
355
+ /** Forgets the clock and any partial step, so the next frame runs no burst. */
356
+ resetClock() {
357
+ this.lastTime = null;
358
+ this.stepRemainder = 0;
359
+ this.snapResidual = 0;
360
+ }
361
+ /**
362
+ * One animation frame (ADR 0014): the elapsed wall-clock time joins the
363
+ * retained remainder, and as many whole Simulation Steps as it holds run
364
+ * — capped, with the excess dropped, so a hitch can neither spiral nor
365
+ * play in slow motion. Not simulating: no time accrues at all. The
366
+ * measured duration is frame-rate-snapped first (round 2 correctness) so
367
+ * sub-millisecond timestamp jitter at an exact cadence like 60 Hz can't
368
+ * flip the whole-steps floor and judder 0/2/0/2.
369
+ */
370
+ tick(time, onStep) {
371
+ const measured = this.lastTime === null ? 0 : (time - this.lastTime) / 1000;
335
372
  this.lastTime = time;
336
- this.runFrame(dt);
373
+ if (!this.simulate) {
374
+ this.stepRemainder = 0;
375
+ this.snapResidual = 0;
376
+ this.runFrame(0);
377
+ return;
378
+ }
379
+ const { elapsed, residual } = snapElapsedToStep(measured, this.snapResidual);
380
+ this.snapResidual = residual;
381
+ const { steps, remainder } = consumeSimulationSteps(this.stepRemainder, elapsed);
382
+ this.stepRemainder = remainder;
383
+ this.runFrame(steps, onStep);
337
384
  }
338
- runFrame(dt) {
385
+ /**
386
+ * Runs `steps` Simulation Steps back to back, then the once-per-frame
387
+ * tail: audio activity and placements, the UI overlay and the render
388
+ * (CA-5). A queued scene swap flushes at the very start of the frame —
389
+ * loadSceneByName's contract — and again before every step after the
390
+ * first (CA-4), so two steps in one frame never see the same press
391
+ * twice or the outgoing scene once too often; a frame that runs zero
392
+ * steps (round 2 correctness) still flushes, so it never renders/
393
+ * audio-places the outgoing scene one frame longer than it should.
394
+ */
395
+ runFrame(steps, onStep) {
339
396
  this.insideFrame = true;
340
397
  try {
341
- // Flushes a scene swap enqueued mid-frame last time (CA-7): applied
342
- // before this frame's own simulation, so the incoming scene's
343
- // entities are present only from this next frame onward.
344
398
  this.flushPendingSceneLoad();
345
399
  if (this.simulate) {
346
- for (const entity of [...this.entities]) {
347
- const schedule = this.componentUpdateSchedule(entity);
348
- if (!schedule)
349
- continue;
350
- for (const component of schedule)
351
- component.onUpdate?.(dt);
400
+ // Re-read every iteration, not just once before the loop: a
401
+ // component or host callback can set `simulate = false` mid-step,
402
+ // and the remaining steps of this catch-up frame must not run.
403
+ for (let index = 0; index < steps && this.simulate; index += 1) {
404
+ // Step 0 was just flushed above; only later steps need it again.
405
+ if (index > 0)
406
+ this.flushPendingSceneLoad();
407
+ this.simulateStep();
408
+ onStep?.();
352
409
  }
353
- this.dispatchCollisions();
354
- this.updateSceneCamera(dt);
355
410
  }
356
- // The UI must react to the pause itself (hide until resumed).
357
- this.ui.setActive(this.simulate);
411
+ else {
412
+ // [DEVIATION 2026-09-12] The editor draws its edit-mode overlays from
413
+ // game.onUpdate with simulate = false (Viewport.tsx), exactly as it
414
+ // did before the fixed step: a non-simulating frame runs no step but
415
+ // still hands the host one callback and closes the input frame.
416
+ this.finishStep();
417
+ }
358
418
  this.audio.setActive(this.simulate);
359
419
  // Positional audio (CA-8): recomputed every frame, on this same pass —
360
420
  // never a second walk of `this.entities`, since `this.audio` already
361
421
  // holds direct references to whichever entities are tracked.
362
422
  this.audio.updatePlacements(this.audioListenerPosition(), (x, y) => this.renderPoint(x, y));
363
- for (const fn of this.updateFns)
364
- fn(dt);
365
- this.input.endFrame();
366
423
  this.renderSurface();
367
424
  }
368
425
  finally {
369
426
  this.insideFrame = false;
370
427
  }
371
428
  }
429
+ /**
430
+ * One Simulation Step: the Component Update Schedule (ADR 0004) in full,
431
+ * collisions, the scene camera and the host's callbacks, every one of
432
+ * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
433
+ */
434
+ simulateStep() {
435
+ for (const entity of [...this.entities]) {
436
+ const schedule = this.componentUpdateSchedule(entity);
437
+ if (!schedule)
438
+ continue;
439
+ for (const component of schedule)
440
+ component.onUpdate?.(SIMULATION_STEP);
441
+ }
442
+ this.dispatchCollisions();
443
+ this.updateSceneCamera(SIMULATION_STEP);
444
+ this.finishStep();
445
+ }
446
+ /** Closes a step (real or the non-simulating stand-in): host callbacks, then the input frame. */
447
+ finishStep() {
448
+ this.runHostUpdates();
449
+ this.input.endFrame();
450
+ }
451
+ runHostUpdates() {
452
+ for (const fn of this.updateFns)
453
+ fn(SIMULATION_STEP);
454
+ }
372
455
  flushPendingSceneLoad() {
373
- const pending = this.pendingSceneLoad;
374
- if (!pending)
375
- return;
376
- this.pendingSceneLoad = null;
377
- pending();
456
+ // Drains the whole chain, not just one level: a loadSceneByName called
457
+ // from the incoming scene's onReady (still insideFrame) re-queues
458
+ // pendingSceneLoad, and a frame that runs zero steps never reaches the
459
+ // per-step flush that would otherwise pick it up next. Capped like the
460
+ // state machine's chained-transition loop (MAX_CHAINED_HOPS), so a
461
+ // degenerate scene cycle can't hang here either.
462
+ for (let hops = 0; hops < MAX_CHAINED_HOPS && this.pendingSceneLoad; hops += 1) {
463
+ const pending = this.pendingSceneLoad;
464
+ this.pendingSceneLoad = null;
465
+ pending();
466
+ }
378
467
  }
379
468
  unregisterRuntimeBridge = () => {
380
469
  window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
@@ -420,24 +509,36 @@ export class Game {
420
509
  }
421
510
  componentUpdateSchedule(entity) {
422
511
  const components = [...entity.components];
423
- const registry = {
424
- ...(this.registry?.components ?? {}),
425
- };
426
512
  const byName = new Map();
427
513
  const names = [];
428
514
  const signatureParts = [];
429
515
  for (const component of components) {
430
516
  const Class = component.constructor;
431
517
  const name = Class.componentName;
432
- registry[name] = Class;
433
518
  names.push(name);
434
519
  byName.set(name, component);
435
520
  signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
436
521
  [...new Set(Class.updateAfter ?? [])].sort().join(','));
437
522
  }
523
+ const signature = signatureParts.sort().join('|');
524
+ // The resolve below (duplicate check + Tarjan SCC + Kahn sort) is pure
525
+ // for a fixed composition, and this method now runs once per entity per
526
+ // Simulation Step rather than once per rendered frame: skip it entirely
527
+ // when nothing about this entity's components changed since last time.
528
+ const cached = this.updateScheduleCache.get(entity);
529
+ if (cached && cached.signature === signature) {
530
+ return cached.order.map((name) => byName.get(name));
531
+ }
532
+ const registry = {
533
+ ...(this.registry?.components ?? {}),
534
+ };
535
+ for (const component of components) {
536
+ const Class = component.constructor;
537
+ registry[Class.componentName] = Class;
538
+ }
438
539
  const result = resolveComponentUpdateSchedule(names, registry);
439
540
  if (!result.ok) {
440
- const signature = signatureParts.sort().join('|');
541
+ this.updateScheduleCache.delete(entity);
441
542
  if (this.invalidUpdateCompositions.get(entity) !== signature) {
442
543
  this.invalidUpdateCompositions.set(entity, signature);
443
544
  console.error(`[waica] invalid component update schedule for "${entity.name}": ` +
@@ -446,6 +547,7 @@ export class Game {
446
547
  return null;
447
548
  }
448
549
  this.invalidUpdateCompositions.delete(entity);
550
+ this.updateScheduleCache.set(entity, { signature, order: result.order });
449
551
  return result.order.map((name) => byName.get(name));
450
552
  }
451
553
  updateSceneCamera(dt) {
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export type { ProjectedPoint } from './projection.js';
13
13
  export { spritePlacement } from './sprite-placement.js';
14
14
  export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
15
15
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
16
+ export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
16
17
  export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
17
18
  export { Entity } from './entity.js';
18
19
  export { Component } from './component.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { isYSortParticipant, ySortZ } from './render-sort.js';
5
5
  export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
6
6
  export { spritePlacement } from './sprite-placement.js';
7
7
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
8
+ export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
8
9
  export { Entity } from './entity.js';
9
10
  export { Component } from './component.js';
10
11
  export { authoringDefaults } from './authoring-defaults.js';
@@ -9,9 +9,10 @@ export type RuntimeMode = 'paused' | 'real-time';
9
9
  * pre-CA-10 engine simply lacks this field, which is exactly what callers
10
10
  * gating on capabilities check for (review finding #4) — protocol 1 alone
11
11
  * doesn't distinguish an engine that silently no-ops an unknown operation
12
- * from one that runs it.
12
+ * from one that runs it. `fixed-step` (ADR 0014) announces that `step`
13
+ * advances whole 1/60 s Simulation Steps and takes no `dt`.
13
14
  */
14
- export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene'];
15
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step'];
15
16
  export interface RuntimeMetadata {
16
17
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
17
18
  engineVersion: string;
@@ -25,9 +26,10 @@ export type RuntimeControlRequest = {
25
26
  action: string;
26
27
  } | {
27
28
  operation: 'pause' | 'resume';
28
- } | {
29
+ }
30
+ /** Advances `frames` whole Simulation Steps (1/60 s each, ADR 0014); default 1. */
31
+ | {
29
32
  operation: 'step';
30
- dt?: number;
31
33
  frames?: number;
32
34
  } | {
33
35
  operation: 'click';
@@ -62,8 +64,14 @@ export interface RuntimeBridgeActivation {
62
64
  }
63
65
  export declare function activeRuntimeBridgeHook(): RuntimeBridgeActivation | null;
64
66
  export interface RuntimeBridgeHost {
65
- step(dt: number): void;
66
- resume(frame: (dt: number) => void): void;
67
+ /**
68
+ * Runs exactly one Simulation Step and renders; `onStep` is told only if a
69
+ * step actually ran (the Game may be non-simulating, in which case this
70
+ * renders a frame but advances nothing).
71
+ */
72
+ step(onStep: () => void): void;
73
+ /** Starts clock-driven playback; `onStep` is told after every Simulation Step. */
74
+ resume(onStep: () => void): void;
67
75
  pause(): void;
68
76
  injectAction(action: string, operation: 'press' | 'hold' | 'release'): boolean;
69
77
  availableActions(): string[];
@@ -81,12 +89,13 @@ export declare class EngineRuntimeBridge implements RuntimeBridge {
81
89
  readonly engineVersion: string;
82
90
  private registered;
83
91
  private mode;
92
+ /** Simulation Steps advanced since registration, paused or real-time alike. */
84
93
  private frame;
85
- private simulationTime;
86
94
  constructor(surface: HTMLCanvasElement, activation: RuntimeBridgeActivation, host: RuntimeBridgeHost);
87
95
  metadata(): RuntimeMetadata;
88
96
  inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
89
97
  control(request: RuntimeControlRequest): RuntimeControlResult;
98
+ /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
90
99
  private advance;
91
100
  unregister(): void;
92
101
  }
@@ -1,4 +1,5 @@
1
1
  import enginePackage from '../package.json' with { type: 'json' };
2
+ import { SIMULATION_STEP } from './fixed-step.js';
2
3
  export const RUNTIME_BRIDGE_PROTOCOL_VERSION = 1;
3
4
  export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
4
5
  /**
@@ -8,9 +9,10 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
8
9
  * pre-CA-10 engine simply lacks this field, which is exactly what callers
9
10
  * gating on capabilities check for (review finding #4) — protocol 1 alone
10
11
  * doesn't distinguish an engine that silently no-ops an unknown operation
11
- * from one that runs it.
12
+ * from one that runs it. `fixed-step` (ADR 0014) announces that `step`
13
+ * advances whole 1/60 s Simulation Steps and takes no `dt`.
12
14
  */
13
- export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene'];
15
+ export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene', 'fixed-step'];
14
16
  export class RuntimeBridgeOperationError extends Error {
15
17
  code;
16
18
  availableActions;
@@ -43,8 +45,8 @@ export class EngineRuntimeBridge {
43
45
  engineVersion = enginePackage.version;
44
46
  registered = true;
45
47
  mode = 'paused';
48
+ /** Simulation Steps advanced since registration, paused or real-time alike. */
46
49
  frame = 0;
47
- simulationTime = 0;
48
50
  constructor(surface, activation, host) {
49
51
  this.surface = surface;
50
52
  this.activation = activation;
@@ -56,7 +58,8 @@ export class EngineRuntimeBridge {
56
58
  engineVersion: this.engineVersion,
57
59
  mode: this.mode,
58
60
  frame: this.frame,
59
- simulationTime: this.simulationTime,
61
+ // Derived, never summed: 60 steps are exactly 1 s, with no float drift.
62
+ simulationTime: this.frame * SIMULATION_STEP,
60
63
  capabilities: RUNTIME_BRIDGE_CAPABILITIES,
61
64
  };
62
65
  }
@@ -74,7 +77,7 @@ export class EngineRuntimeBridge {
74
77
  case 'resume':
75
78
  if (this.mode === 'paused') {
76
79
  this.mode = 'real-time';
77
- this.host.resume((dt) => this.advance(dt));
80
+ this.host.resume(() => this.advance());
78
81
  }
79
82
  break;
80
83
  case 'press':
@@ -89,16 +92,18 @@ export class EngineRuntimeBridge {
89
92
  if (this.mode !== 'paused') {
90
93
  throw new RuntimeBridgeOperationError('runtime-invalid-state', 'step is only available while the Runtime Bridge is paused.');
91
94
  }
92
- const dt = request.dt ?? 1 / 60;
93
- const frames = request.frames ?? 1;
94
- if (!Number.isFinite(dt) || dt <= 0 || dt > 0.1) {
95
- throw new RuntimeBridgeOperationError('runtime-operation-failed', 'dt must be finite and greater than 0 and at most 0.1.');
95
+ // A caller-chosen dt is rejected outright, never ignored: a pre-ADR-0014
96
+ // client that still sends one would otherwise believe it stepped by it.
97
+ if ('dt' in request) {
98
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'step takes no dt: it advances whole Simulation Steps of 1/60 s each; pass frames (1 through 600) instead.');
96
99
  }
100
+ const frames = request.frames ?? 1;
97
101
  if (!Number.isInteger(frames) || frames < 1 || frames > 600) {
98
102
  throw new RuntimeBridgeOperationError('runtime-operation-failed', 'frames must be an integer from 1 through 600.');
99
103
  }
100
- for (let index = 0; index < frames; index += 1)
101
- this.advance(dt);
104
+ for (let index = 0; index < frames; index += 1) {
105
+ this.host.step(() => this.advance());
106
+ }
102
107
  break;
103
108
  }
104
109
  case 'click': {
@@ -122,10 +127,9 @@ export class EngineRuntimeBridge {
122
127
  }
123
128
  return { ...this.metadata(), heldActions: this.host.heldActions() };
124
129
  }
125
- advance(dt) {
126
- this.host.step(dt);
130
+ /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
131
+ advance() {
127
132
  this.frame += 1;
128
- this.simulationTime += dt;
129
133
  }
130
134
  unregister() {
131
135
  if (!this.registered)
@@ -1,6 +1,7 @@
1
1
  import { installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from '../animation/directional.js';
2
2
  import { Component } from '../component.js';
3
3
  import { AnimatedSprite } from '../components/animated-sprite.js';
4
+ import { MAX_CHAINED_HOPS, SIMULATION_TIME_EPSILON } from '../fixed-step.js';
4
5
  import { closestLogicSet, logicSet, registeredLogicSets, } from './hooks.js';
5
6
  /** Whether one trigger fires. Unknown or malformed triggers never fire. */
6
7
  export function evaluateTrigger(on, env) {
@@ -11,8 +12,15 @@ export function evaluateTrigger(on, env) {
11
12
  const arg = on.slice(sep + 1);
12
13
  if (kind === 'input')
13
14
  return env.justPressed(arg);
15
+ // A float epsilon of tolerance, not half a Simulation Step: `elapsed` is
16
+ // a sum of many SIMULATION_STEP-sized dts, and float error can leave it a
17
+ // hair under an exact multiple (15 additions of 1/60 give
18
+ // 0.24999999999999997, not 0.25), which a bare `>=` would fire one whole
19
+ // step late. A tolerance as wide as half a step instead fired non-multiple
20
+ // durations one whole step early, since a target can sit closer to the
21
+ // step below than to the one it truly belongs to.
14
22
  if (kind === 'timer')
15
- return env.elapsed >= Number(arg);
23
+ return env.elapsed + SIMULATION_TIME_EPSILON >= Number(arg);
16
24
  if (kind === 'signal')
17
25
  return env.signals.has(arg);
18
26
  return false;
@@ -105,7 +113,7 @@ export class StateMachine extends Component {
105
113
  this.elapsed += dt;
106
114
  // Chained transitions settle within the frame (e.g. land → idle → run),
107
115
  // capped so a degenerate cyclic graph can't hang the loop.
108
- for (let hops = 0; hops < 8; hops++) {
116
+ for (let hops = 0; hops < MAX_CHAINED_HOPS; hops++) {
109
117
  const edge = nextTransition(this.states, this.current, this.env());
110
118
  // A '*' edge is re-merged against whatever state the loop just
111
119
  // entered, so a still-queued signal (signals.clear() only runs after
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",