Run behaviors on a fixed step with an On fixed update node

Adding up the frame's dt in On update makes anything that counts in
steps depend on the frame rate. A pitch sweep sounds a little
different on every run. On fixed update runs 60 times per second of
game time with dt exactly 1/60, so the same graph gives the same
result every time.

- fixedSteps in src/core/time.ts is the one rule for the fixed step:
  spend the frame in steps of 1/60, keep the rest, catch up 8 steps
  at most after a hitch.
- The physics now counts its steps with fixedSteps too. It behaves
  as before, and On fixed update runs exactly as often as the engine
  steps.
- The steps run after On mouse wheel and before On update, each as a
  kind of its own with the usual Order setting.
- Each step gets its own context, so a node inside that reads dt
  (Time, Move, Apply force) gets 1/60 rather than the frame.
- A paused game has no steps, also for a behavior that runs while
  paused.
- The engine still takes all of a frame's steps before the behaviors
  run. A force set in On fixed update acts from the next frame on.

Tests:
- fixed-update.test.ts (new): 5 tests. Step counting, uneven frames
  adding up to 60 steps a second, the hitch cap, two steps at 30 Hz
  before On update with dt 1/60 for the event and for Time, one step
  every other frame at 120 Hz, none while paused.
- event-order, node-catalog, physics-system, game-pause: 430 tests
  together with the new file, all green.
- Counter-check: with the frame's dt inside a step and without the
  pause guard both world tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 21:46:15 +02:00
1 parent c7e9769f90
commit ded0022903
8 files changed
+140 -14

No files matched your search

+14 -3
View File
@@ -31,6 +31,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
import { treeOrder } from '../../core/model/hierarchy';
import { resolveObjects } from '../../core/model/instance';
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
import { FIXED_STEP, fixedSteps } from '../../core/time';
import { type AudioSink, SILENT_AUDIO } from './Audio';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -182,6 +183,8 @@ export class BehaviorSystem implements System {
private restarted: ReadonlySet<string> = new Set();
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
private pending: StateChange[] = [];
/** Game time not yet spent on a fixed step (see `fixedSteps`); counted like the physics' own. */
private fixedBacklog = 0;
/** Set when the scene may have been edited, so the next sync puts the instances in order again. */
private unranked = true;
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
@@ -630,14 +633,22 @@ export class BehaviorSystem implements System {
y: pointer.y,
})
: [],
() => pulsesOf(ticking, 'OnUpdate', { dt }),
];
for (const kind of kinds) {
this.runPulses(kind(), contextOf, true);
const run = (pulses: Pulse[], context = contextOf) => {
this.runPulses(pulses, context, true);
// After the kind, not inside it: a state entered halfway through `On update` must not push
// its events between the nodes that are still to run.
this.flushStateChanges();
};
for (const kind of kinds) run(kind());
if (!frozen) {
const fixed = fixedSteps(this.fixedBacklog, dt);
this.fixedBacklog = fixed.backlog;
// A context of its own per step: what a node reads as `dt` inside is the step, not the frame.
for (let step = 0; step < fixed.steps; step++)
run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP));
}
run(pulsesOf(ticking, 'OnUpdate', { dt }));
this.reap(world);
this.flushStateChanges();
this.persist();