The engine loop
The host runs a single requestAnimationFrame loop with a fixed-step accumulator. Simulation advances in whole steps of 1/60 s; rendering happens whenever the browser asks. The two are joined by alpha, the fraction of a step left in the accumulator, which is how far to blend a transform from where it was to where it is.
Each frame, in order:
beginFrame()on every module that implements it — input capture.fixedUpdate(dt)on every module, 0 to 5 times,dtalways1/60.update(dtReal, alpha)on every module, exactly once.renderer.render(scene, camera).endFrame()on every module that implements it.
Game logic only ever sees the fixed step, so a replay of the same inputs produces the same simulation on any machine.
The substep cap and the death spiral
A frame that takes 200 ms owes twelve steps. Running all twelve makes the next frame slower still, which owes more — the accumulator runs away and the game freezes. So the loop runs at most maxSubsteps (default 5) and throws the rest away: time is lost, and the game stays responsive. FrameTiming.clamped says when that happened.
The other way round, a 60 Hz display driving a 60 Hz simulation lands within a rounding error of a whole step every frame. The accumulator comparison carries a microsecond of slack so float noise cannot turn that into an alternating zero-step/two-step judder.
Interpolation
TransformStore keeps every entity's transform twice: as it was at the end of the previous fixed step, and as it is now. commit() moves current to previous; writeInterpolated(id, object3d, alpha) writes the blend onto a three object — lerped position and scale, slerped rotation, straight out of flat typed arrays with no temporaries.
Driving it yourself
createFixedLoop is pure and has no requestAnimationFrame in it, so it can be driven from a test, a benchmark or a replay:
ts
import { createFixedLoop } from '@aosengine/core';
let ticks = 0;
const loop = createFixedLoop({
fixedDt: 1 / 60,
maxSubsteps: 5,
fixedUpdate: () => {
ticks += 1;
},
update: (dtReal, alpha) => {
console.log(dtReal, alpha);
},
});
loop.step(0); // baseline frame: no fixed step runs
const timing = loop.step(1000 / 30); // 33.3 ms buys two steps
console.log(ticks, timing.substeps, timing.alpha); // 2 2 <0..1>Rules
- Nothing in
fixedUpdateorupdatemay allocate. They run at least 60 times a second. Preallocate typed arrays, pool objects, use dirty flags. updateis for presentation,fixedUpdateis for simulation. Anything that must be deterministic belongs in the fixed step.time.timeScalescales the simulation, not the frame rate: the loop still steps at 60 Hz, but each step advancestime.elapsedby less.