Skip to content

Commit 8fd03e9

Browse files
authored
refactor: implement new FixedTimeStep class for runtime gameloop (#138)
1 parent 7f3e8e5 commit 8fd03e9

File tree

7 files changed

+162
-339
lines changed

7 files changed

+162
-339
lines changed

.changeset/five-pants-sleep.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@jolly-pixel/runtime": minor
3+
"@jolly-pixel/engine": minor
4+
---
5+
6+
Integrate new FixedTimeStep for gameloop with fixedUpdate and classical update
Lines changed: 34 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,76 @@
11
## FixedTimeStep
22

3-
`FixedTimeStep` decouples the game's **logical update rate** from
4-
the display's frame rate. Instead of updating once per rendered
5-
frame (which varies with hardware and browser load), it
6-
accumulates elapsed time and runs a fixed number of updates at a
7-
constant interval. This ensures deterministic, reproducible
8-
physics and gameplay regardless of rendering performance.
3+
`FixedTimeStep` decouples the game's **logical update rate** (e.g. physics, simulation) from the display's frame rate. Instead of updating once per rendered frame (which varies with hardware and browser load), it accumulates elapsed time and runs a fixed number of updates at a constant interval. This ensures deterministic, reproducible physics and gameplay regardless of rendering performance.
94

10-
The implementation guards against the
11-
["doom spiral"](http://blogs.msdn.com/b/shawnhar/archive/2011/03/25/technical-term-that-should-exist-quot-black-pit-of-despair-quot.aspx)
12-
— a situation where each tick takes longer than the previous one —
13-
by capping accumulated time to at most 5 update intervals.
14-
15-
### How it works
16-
17-
```
18-
requestAnimationFrame
19-
20-
├─ clock.update() ← advance the timer
21-
22-
├─ accumulatedTime += dt ← add frame delta
23-
24-
└─ while accumulatedTime >= updateInterval
25-
├─ callback(deltaTime) ← fixed-step game update
26-
└─ accumulatedTime -= updateInterval
27-
```
28-
29-
On every browser frame the caller feeds the accumulated time into
30-
`tick()`. The method consumes as many fixed-size intervals as
31-
possible, calls the update callback for each one, and returns how
32-
much time is left over for the next frame.
5+
The implementation guards against the ["doom spiral"](http://blogs.msdn.com/b/shawnhar/archive/2011/03/25/technical-term-that-should-exist-quot-black-pit-of-despair-quot.aspx) — a situation where each tick takes longer than the previous one — by capping accumulated time to at most 5 update intervals.
336

347
### Usage
358

369
```ts
3710
import { FixedTimeStep } from "@jolly-pixel/engine";
3811

3912
const fixedTimeStep = new FixedTimeStep();
40-
fixedTimeStep.setFps(60);
13+
fixedTimeStep.setFps(60); // (render FPS, fixed update FPS)
4114

42-
let accumulatedTime = 0;
15+
// Start the timer before your main loop
16+
fixedTimeStep.start();
4317

4418
function loop() {
45-
const { updates, timeLeft } = fixedTimeStep.tick(
46-
accumulatedTime,
47-
(deltaTime) => {
48-
// Run one fixed-step update
49-
const exited = game.update(deltaTime);
50-
51-
// Returning true stops processing remaining steps
52-
return exited;
19+
fixedTimeStep.tick({
20+
fixedUpdate: (fixedDelta) => {
21+
// Run physics or deterministic logic here
22+
game.fixedUpdate(fixedDelta);
23+
},
24+
update: (interpolation, delta) => {
25+
// Use interpolation for smooth rendering
26+
game.render(interpolation, delta);
5327
}
54-
);
28+
});
5529

56-
accumulatedTime = timeLeft;
57-
game.render();
5830
requestAnimationFrame(loop);
5931
}
6032

6133
requestAnimationFrame(loop);
6234
```
6335

64-
### TimerAdapter
65-
66-
`FixedTimeStep` relies on a `TimerAdapter` for time measurement.
67-
By default it uses Three.js's `THREE.Timer`, but any object that
68-
satisfies the interface can be injected (useful for testing with
6936
deterministic time):
7037

38+
### Callbacks
39+
40+
`FixedTimeStep` uses a callback object:
41+
7142
```ts
72-
interface TimerAdapter {
73-
/** Advance the internal clock. Called once per tick. */
74-
update: () => void;
75-
/** Return the elapsed time since the last update (in ms). */
76-
getDelta: () => number;
43+
interface FixedTimeStepCallbacks {
44+
fixedUpdate: (fixedDelta: number) => void;
45+
update?: (interpolation: number, delta: number) => void;
7746
}
7847
```
7948

80-
```ts
81-
// Custom clock for tests
82-
const mockClock: TimerAdapter = {
83-
update: () => {},
84-
getDelta: () => 16.67
85-
};
86-
87-
const fixedTimeStep = new FixedTimeStep(mockClock);
88-
```
49+
- `fixedUpdate` is called one or more times per frame, using a fixed timestep (in ms). Use this for physics and deterministic logic.
50+
- `update` is called once per frame
8951

90-
### Configuring the frame rate
52+
#### Interpolation
9153

92-
The target frame rate defaults to **60 FPS** and can be changed
93-
with `setFps`. Values are clamped between `1` and
94-
`FixedTimeStep.MaxFramesPerSecond` (60):
54+
The `interpolation` value passed to `update` allows you to render objects smoothly between physics steps. For example, if your physics runs at 60Hz but your display is 144Hz, you can interpolate between the previous and current physics states:
9555

9656
```ts
97-
fixedTimeStep.setFps(30);
57+
const renderedPosition = (1 - interpolation) * previousPosition + interpolation * currentPosition;
9858
```
9959

100-
The update interval is derived as `1000 / framesPerSecond`
101-
(in milliseconds).
60+
This makes movement appear smooth, even if the physics steps are less frequent than the rendering frames.
10261

103-
### Tick result
62+
### Configuring the frame rate
10463

105-
`tick()` returns an object describing what happened during the
106-
call:
64+
The target frame rate defaults to **60 FPS** for both rendering and fixed updates. You can change them with `setFps(renderFps, fixedFps)`. Values are clamped between `1` and `FixedTimeStep.MaxFramesPerSecond` (60):
10765

10866
```ts
109-
interface TickResult {
110-
/** Number of fixed updates that were executed. */
111-
updates: number;
112-
/** Remaining accumulated time to carry over to the next frame. */
113-
timeLeft: number;
114-
}
67+
fixedTimeStep.setFps(30, 60); // 30 FPS render, 60 FPS fixed update
11568
```
11669

117-
- **`updates === 0`** — the frame was too short for a full step;
118-
the leftover time is carried over.
119-
- **`updates > 0`** — the callback ran that many times, each with
120-
a consistent `deltaTime`.
121-
- **Early exit** — if the callback returns `true`, processing
122-
stops immediately and the remaining time is returned.
70+
The fixed update interval is derived as `1000 / fixedFramesPerSecond` (in milliseconds).
12371

12472
### Doom spiral protection
12573

126-
If the browser stalls (e.g. tab switch, debugger breakpoint), the
127-
accumulated time can grow very large. Without a cap, the engine
128-
would try to catch up with dozens of updates in a single frame,
129-
making the problem worse. `FixedTimeStep` limits catch-up to at
130-
most **5 intervals**, discarding excess time.
74+
If the browser stalls (e.g. tab switch, debugger breakpoint), the accumulated time can grow very large. Without a cap, the engine would try to catch up with dozens of updates in a single frame, making the problem worse.
75+
76+
`FixedTimeStep` limits catch-up to at most **5 intervals**, discarding excess time.
Lines changed: 97 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,122 @@
1-
// Import Third-party Dependencies
2-
import * as THREE from "three";
1+
// CONSTANTS
2+
const kDefaultMaxFps = 60;
3+
const kDefaultFixedFps = 60;
4+
const kMaxAccumulatedSteps = 5;
35

4-
export interface TimerAdapter {
5-
update: () => void;
6-
getDelta: () => number;
6+
class InternalTimer {
7+
#last = 0;
8+
#delta = 0;
9+
10+
start() {
11+
this.#last = performance.now();
12+
this.#delta = 0;
13+
}
14+
15+
update() {
16+
const now = performance.now();
17+
this.#delta = now - this.#last;
18+
this.#last = now;
19+
}
20+
21+
getDelta() {
22+
return this.#delta;
23+
}
24+
}
25+
26+
export interface FixedTimeStepCallbacks {
27+
// Called every fixed step (physics/deterministic logic)
28+
fixedUpdate: (fixedDelta: number) => void;
29+
// Called every frame (rendering/variable logic)
30+
update?: (interpolation: number, delta: number) => void;
731
}
832

933
export class FixedTimeStep {
10-
static MaxFramesPerSecond = 60;
34+
static MaxFramesPerSecond = kDefaultMaxFps;
35+
36+
#timer: InternalTimer;
37+
#accumulated = 0;
38+
#fixedDelta: number;
39+
#maxAccumulated: number;
40+
#running = false;
1141

12-
framesPerSecond = 60;
13-
timestep = 1000 / this.framesPerSecond;
14-
clock: TimerAdapter;
42+
framesPerSecond = kDefaultMaxFps;
43+
fixedFramesPerSecond = kDefaultFixedFps;
1544

1645
constructor(
17-
clock: TimerAdapter = new THREE.Timer()
46+
fps: number = kDefaultMaxFps,
47+
fixedFps: number = kDefaultFixedFps
1848
) {
19-
this.clock = clock;
49+
this.framesPerSecond = fps;
50+
this.fixedFramesPerSecond = fixedFps;
51+
this.#fixedDelta = 1000 / this.fixedFramesPerSecond;
52+
this.#maxAccumulated = kMaxAccumulatedSteps * this.#fixedDelta;
53+
this.#timer = new InternalTimer();
2054
}
2155

2256
setFps(
23-
framesPerSecond: number | undefined
57+
fps: number,
58+
fixedFps: number = fps
2459
): void {
25-
if (!framesPerSecond) {
26-
return;
60+
if (typeof fps === "number") {
61+
this.framesPerSecond = Math.max(1, Math.min(fps, FixedTimeStep.MaxFramesPerSecond));
62+
}
63+
if (typeof fixedFps === "number") {
64+
this.fixedFramesPerSecond = Math.max(1, Math.min(fixedFps, FixedTimeStep.MaxFramesPerSecond));
2765
}
2866

29-
this.framesPerSecond = THREE.MathUtils.clamp(
30-
framesPerSecond,
31-
1,
32-
FixedTimeStep.MaxFramesPerSecond
33-
);
34-
this.timestep = 1000 / this.framesPerSecond;
67+
this.#fixedDelta = 1000 / this.fixedFramesPerSecond;
68+
this.#maxAccumulated = kMaxAccumulatedSteps * this.#fixedDelta;
3569
}
3670

71+
start() {
72+
this.#accumulated = 0;
73+
this.#timer.start();
74+
this.#running = true;
75+
}
76+
77+
stop() {
78+
this.#running = false;
79+
}
80+
81+
/**
82+
* Main loop. Call this from your requestAnimationFrame or similar.
83+
* @param callbacks { fixedUpdate, update }
84+
*/
3785
tick(
38-
accumulatedTime: number,
39-
callback?: (deltaTime: number) => boolean
40-
): { updates: number; timeLeft: number; } {
41-
this.clock.update();
42-
43-
const updateInterval = this.timestep;
44-
let newAccumulatedTime = accumulatedTime;
45-
46-
// Limit how many update()s to try and catch up,
47-
// to avoid falling into the "black pit of despair" aka "doom spiral".
48-
// where every tick takes longer than the previous one.
49-
// See http://blogs.msdn.com/b/shawnhar/archive/2011/03/25/technical-term-that-should-exist-quot-black-pit-of-despair-quot.aspx
50-
const maxAccumulatedUpdates = 5;
51-
const maxAccumulatedTime = maxAccumulatedUpdates * updateInterval;
52-
if (newAccumulatedTime > maxAccumulatedTime) {
53-
newAccumulatedTime = maxAccumulatedTime;
86+
callbacks: FixedTimeStepCallbacks
87+
): void {
88+
if (!this.#running) {
89+
return;
5490
}
5591

56-
// Update
57-
let updates = 0;
58-
while (newAccumulatedTime >= updateInterval) {
59-
if (callback?.(updateInterval)) {
92+
this.#timer.update();
93+
let delta = this.#timer.getDelta();
94+
if (delta > 1000) {
95+
// Prevent huge catch-up after tab switch or pause
96+
delta = this.#fixedDelta;
97+
}
98+
this.#accumulated += delta;
99+
if (this.#accumulated > this.#maxAccumulated) {
100+
this.#accumulated = this.#maxAccumulated;
101+
}
102+
103+
// Fixed update steps
104+
let steps = 0;
105+
while (this.#accumulated >= this.#fixedDelta) {
106+
callbacks.fixedUpdate(this.#fixedDelta);
107+
this.#accumulated -= this.#fixedDelta;
108+
steps++;
109+
if (steps > kMaxAccumulatedSteps) {
110+
// Avoid spiral of death
111+
this.#accumulated = 0;
60112
break;
61113
}
62-
newAccumulatedTime -= updateInterval;
63-
updates++;
64114
}
65115

66-
return { updates, timeLeft: newAccumulatedTime };
116+
// Variable update (render/interpolation)
117+
if (callbacks.update) {
118+
const alpha = this.#accumulated / this.#fixedDelta;
119+
callbacks.update(alpha, delta);
120+
}
67121
}
68122
}

packages/engine/src/systems/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type {
1212
} from "./asset/Registry.ts";
1313

1414
export * from "./GameInstance.ts";
15+
export * from "./FixedTimeStep.ts";
1516
export * from "./rendering/index.ts";
1617
export * from "./Scene.ts";
1718

0 commit comments

Comments
 (0)