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

+1 -1
View File
@@ -104,7 +104,7 @@ describe('the order rule', () => {
const events = BUILT_IN_NODES.filter(
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
);
expect(events).toHaveLength(17);
expect(events).toHaveLength(18);
for (const def of events) {
expect(
def.params.map((p) => p.key),
+80
View File
@@ -0,0 +1,80 @@
/**
* `On fixed update`: 60 steps a second of game time, each with dt 1/60, however the frames fall –
* counted by the same rule the physics steps by.
*/
import { describe, expect, it } from 'vitest';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { FIXED_STEP, fixedSteps, MAX_FIXED_STEPS } from '../../src/core/time';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
describe('fixed steps', () => {
it('spends a frame in steps of 1/60 and keeps the rest for the next', () => {
expect(fixedSteps(0, FIXED_STEP).steps).toBe(1);
expect(fixedSteps(0, 1 / 30).steps).toBe(2);
expect(fixedSteps(0, 0).steps).toBe(0);
const half = fixedSteps(0, 1 / 120);
expect(half.steps).toBe(0);
expect(fixedSteps(half.backlog, 1 / 120).steps).toBe(1);
});
it('adds up to the time that passed, however uneven the frames', () => {
let backlog = 0;
let steps = 0;
for (let i = 0; i < 20; i++)
for (const dt of [0.01, 0.02, 0.005, 0.015]) {
const next = fixedSteps(backlog, dt);
backlog = next.backlog;
steps += next.steps;
}
expect(steps).toBe(60);
});
it('catches up a hitch only so far and forgets the rest', () => {
expect(fixedSteps(0, 1)).toEqual({ steps: MAX_FIXED_STEPS, backlog: 0 });
});
});
/** A behavior logging `fixed <dt of the event> <dt a node reads>` per step and `update` per frame. */
function stepped(runWhilePaused = false) {
const f = makeSpriteScene('Box');
const behavior = makeBehavior(f, 'Engine');
if (runWhilePaused) f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runWhilePaused', true));
const edit = graphEditor(f, behavior);
const fixed = edit.addNode('OnFixedUpdate');
const eventDt = edit.addNode('Log');
const nodeDt = edit.addNode('Log');
const update = edit.addNode('OnUpdate');
edit.wire([
[fixed, 'exec', eventDt, 'exec'],
[fixed, 'dt', eventDt, 'message'],
[eventDt, 'exec', nodeDt, 'exec'],
[edit.addNode('Time'), 'dt', nodeDt, 'message'],
[update, 'exec', edit.addNode('Log', { message: 'update' }), 'exec'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return startWorld(f, { behaviors: true });
}
const STEP = String(FIXED_STEP);
describe('On fixed update', () => {
it('runs once per 1/60 of game time, before On update, and dt inside is the step', () => {
const { world, frame } = stepped();
frame(1 / 30);
expect(world.log).toEqual([STEP, STEP, STEP, STEP, 'update']);
world.log.length = 0;
frame(1 / 120);
expect(world.log).toEqual(['update']);
frame(1 / 120);
expect(world.log).toEqual(['update', STEP, STEP, 'update']);
});
it('stands still while the game is paused, also for a behavior that runs then', () => {
const { world, frame } = stepped(true);
world.paused = true;
frame(1 / 30);
frame(1 / 30);
expect(world.log).toEqual(['update', 'update']);
});
});
+1
View File
@@ -2131,6 +2131,7 @@ const EVENT_NODES: Record<string, string> = {
OnClick: 'nodes.test.ts',
OnMouseWheel: 'clip-areas.test.ts',
OnAnimationFinished: 'animation.test.ts',
OnFixedUpdate: 'fixed-update.test.ts',
OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts',
};