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

@@ -0,0 +1,17 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared';
export const OnFixedUpdate: NodeDef = {
type: 'OnFixedUpdate',
label: 'On fixed update',
category: 'event',
description:
'Runs 60 times a second of game time, with dt always 1/60, however long the frame took: a short ' +
'frame may have none, a long one several, each before On update. As often as physics steps. ' +
'Stands still while the game is paused.',
inputs: [],
outputs: [exec(), num('dt')],
params: [orderParam],
hiddenParams: hiddenOrder,
};
+2
View File
@@ -124,6 +124,7 @@ import { OnCollisionStay } from './events/OnCollisionStay';
import { OnDestroy } from './events/OnDestroy'; import { OnDestroy } from './events/OnDestroy';
import { OnDisable } from './events/OnDisable'; import { OnDisable } from './events/OnDisable';
import { OnEnable } from './events/OnEnable'; import { OnEnable } from './events/OnEnable';
import { OnFixedUpdate } from './events/OnFixedUpdate';
import { OnKeyDown } from './events/OnKeyDown'; import { OnKeyDown } from './events/OnKeyDown';
import { OnKeyHeld } from './events/OnKeyHeld'; import { OnKeyHeld } from './events/OnKeyHeld';
import { OnKeyUp } from './events/OnKeyUp'; import { OnKeyUp } from './events/OnKeyUp';
@@ -159,6 +160,7 @@ import { Variables } from './variable/Variables';
export const BUILT_IN_NODES: readonly NodeDef[] = [ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnStart, OnStart,
OnEnable, OnEnable,
OnFixedUpdate,
OnUpdate, OnUpdate,
OnDisable, OnDisable,
OnDestroy, OnDestroy,
+19
View File
@@ -17,6 +17,25 @@ export function effectiveScale(scale: number, paused: boolean): number {
return scale; return scale;
} }
/** The fixed step: physics and `On fixed update` move this far at a time, whatever the frame took. */
export const FIXED_STEP = 1 / 60;
/** After a hitch a fixed clock catches up this many steps at most, and forgets the rest. */
export const MAX_FIXED_STEPS = 8;
/**
* How many fixed steps a frame of `dt` brings, and what it leaves for the next frame. A short frame
* may bring none, a long one several; over time the steps add up to the scaled time that passed.
*/
export function fixedSteps(backlog: number, dt: number): { steps: number; backlog: number } {
let left = backlog + Math.max(0, dt);
let steps = 0;
while (left >= FIXED_STEP - 1e-9 && steps < MAX_FIXED_STEPS) {
left -= FIXED_STEP;
steps++;
}
return { steps, backlog: steps === MAX_FIXED_STEPS ? 0 : Math.max(0, left) };
}
/** How far the clock moves in one frame. */ /** How far the clock moves in one frame. */
export function timeStep(dt: number, scale: number, paused: boolean): number { export function timeStep(dt: number, scale: number, paused: boolean): number {
return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused); return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused);
+6 -10
View File
@@ -22,6 +22,7 @@ import {
shapesOverlap, shapesOverlap,
} from '../../core/properties/collider'; } from '../../core/properties/collider';
import { hasTransformProps } from '../../core/properties/PropertySchema'; import { hasTransformProps } from '../../core/properties/PropertySchema';
import { FIXED_STEP, fixedSteps } from '../../core/time';
import { import {
type BodyType, type BodyType,
colliderMaterial, colliderMaterial,
@@ -66,10 +67,8 @@ import { type Rapier, toMetres, toPixels } from './engine';
import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints'; import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints';
import { passesOneWay, reachOf } from './oneWay'; import { passesOneWay, reachOf } from './oneWay';
/** The engine always steps this long, whatever the frame took. */ /** The engine always steps this long, whatever the frame took – as often as `On fixed update` runs. */
export const PHYSICS_STEP = 1 / 60; export const PHYSICS_STEP = FIXED_STEP;
/** After a hitch the world catches up this many steps at most, and forgets the rest. */
const MAX_STEPS_PER_TICK = 8;
/** /**
* One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below * One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below
@@ -217,19 +216,16 @@ export class PhysicsSystem implements System, CollisionSource {
this.exits = []; this.exits = [];
this.sync(world); this.sync(world);
engine.gravity = this.gravity(world); engine.gravity = this.gravity(world);
this.backlog += dt; const { steps, backlog } = fixedSteps(this.backlog, dt);
let steps = 0; this.backlog = backlog;
const entered = new Set<string>(); const entered = new Set<string>();
while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) { for (let step = 0; step < steps; step++) {
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current; for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current;
this.decideOneWay(world); this.decideOneWay(world);
engine.step(this.queue!, this.hooks ?? undefined); engine.step(this.queue!, this.hooks ?? undefined);
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body); for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
this.collect(world, entered); this.collect(world, entered);
this.backlog -= PHYSICS_STEP;
steps++;
} }
this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog);
this.writeBack(this.backlog / PHYSICS_STEP); this.writeBack(this.backlog / PHYSICS_STEP);
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair); this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
this.triggers.update(world); this.triggers.update(world);
+14 -3
View File
@@ -31,6 +31,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
import { treeOrder } from '../../core/model/hierarchy'; import { treeOrder } from '../../core/model/hierarchy';
import { resolveObjects } from '../../core/model/instance'; import { resolveObjects } from '../../core/model/instance';
import { findAssetOf, findScene, type Project } from '../../core/model/Project'; 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 AudioSink, SILENT_AUDIO } from './Audio';
import type { Entity } from '../Entity'; import type { Entity } from '../Entity';
import type { World } from '../World'; import type { World } from '../World';
@@ -182,6 +183,8 @@ export class BehaviorSystem implements System {
private restarted: ReadonlySet<string> = new Set(); private restarted: ReadonlySet<string> = new Set();
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */ /** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
private pending: StateChange[] = []; 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. */ /** Set when the scene may have been edited, so the next sync puts the instances in order again. */
private unranked = true; private unranked = true;
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ /** 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, y: pointer.y,
}) })
: [], : [],
() => pulsesOf(ticking, 'OnUpdate', { dt }),
]; ];
for (const kind of kinds) { const run = (pulses: Pulse[], context = contextOf) => {
this.runPulses(kind(), contextOf, true); this.runPulses(pulses, context, true);
// After the kind, not inside it: a state entered halfway through `On update` must not push // 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. // its events between the nodes that are still to run.
this.flushStateChanges(); 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.reap(world);
this.flushStateChanges(); this.flushStateChanges();
this.persist(); this.persist();
+1 -1
View File
@@ -104,7 +104,7 @@ describe('the order rule', () => {
const events = BUILT_IN_NODES.filter( const events = BUILT_IN_NODES.filter(
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR, (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) { for (const def of events) {
expect( expect(
def.params.map((p) => p.key), 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', OnClick: 'nodes.test.ts',
OnMouseWheel: 'clip-areas.test.ts', OnMouseWheel: 'clip-areas.test.ts',
OnAnimationFinished: 'animation.test.ts', OnAnimationFinished: 'animation.test.ts',
OnFixedUpdate: 'fixed-update.test.ts',
OnStateEntered: 'state-machine.test.ts', OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts', OnStateExited: 'state-machine.test.ts',
}; };