Ever wanted to freeze time mid-jump to examine exactly what's happening? Or slow down that complex collision to see where things go wrong? A time control system gives you godlike powers over your game's temporal flow.
Let's build a system that can pause, slow down, speed up, and even step through your game frame by frame.
In most game loops, deltaTime flows from the engine straight to your systems. You're at the mercy of 60fps, making it nearly impossible to debug fast-moving interactions or examine single-frame events.
What we need is a gatekeeper - something that sits between the engine and your game logic, controlling exactly how much time each system receives.
src/components/TimeControl.ts
import { Component, Entity, World, System } from "~/lib/ECS";
export interface TimeControlInput {
enabled?: boolean;
isPaused?: boolean;
timeScale?: number;
maxTimeScale?: number;
minTimeScale?: number;
frameAdvanceRequested?: boolean;
}
export class TimeControlComponent extends Component {
public enabled: boolean;
public isPaused: boolean;
public timeScale: number;
public maxTimeScale: number;
public minTimeScale: number;
public frameAdvanceRequested: boolean;
public actualDeltaTime: number = 0;
public modifiedDeltaTime: number = 0;
public frameCount: number = 0;
constructor(data: TimeControlInput = {}) {
super(data);
this.enabled = data.enabled ?? true;
this.isPaused = data.isPaused ?? false;
this.timeScale = data.timeScale ?? 1.0;
this.maxTimeScale = data.maxTimeScale ?? 5.0;
this.minTimeScale = data.minTimeScale ?? 0.1;
this.frameAdvanceRequested = data.frameAdvanceRequested ?? false;
}
}
This component tracks everything about time manipulation:
isPaused: Complete time freeze
timeScale: Speed multiplier (0.5 = half speed, 2.0 = double speed)
frameAdvanceRequested: Single frame step trigger
actualDeltaTime vs modifiedDeltaTime: Before and after time modification
src/components/TimeControl.ts
export class TimeControlSystem extends System {
private timeControlComponent: TimeControlComponent | null = null;
private debugDisplay: boolean = true;
constructor(world: World, componentClasses = [TimeControlComponent]) {
super(world, componentClasses);
window.addEventListener("keydown", this.keyDown.bind(this));
window.addEventListener("keyup", this.keyUp.bind(this));
this.createDebugDisplay();
}
private createDebugDisplay() {
if (!this.debugDisplay) return;
const display = document.createElement("div");
display.id = "time-control-debug";
display.style.cssText = `
position: fixed;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
font-family: monospace;
font-size: 12px;
border-radius: 5px;
z-index: 1000;
pointer-events: none;
`;
document.body.appendChild(display);
}
The system creates a debug overlay showing current time state and handles all keyboard input. It's designed to be the first system processed each frame.
src/components/TimeControl.ts
keyDown(event: KeyboardEvent) {
if (!this.timeControlComponent || !this.timeControlComponent.enabled) return;
const component = this.timeControlComponent;
switch (event.code) {
case "Space":
event.preventDefault();
component.isPaused = !component.isPaused;
console.log(`Time ${component.isPaused ? 'PAUSED' : 'RESUMED'}`);
break;
case "ArrowRight":
if (component.isPaused) {
event.preventDefault();
component.frameAdvanceRequested = true;
console.log("Frame advance requested");
}
break;
case "ArrowLeft":
if (component.isPaused) {
event.preventDefault();
component.frameAdvanceRequested = true;
component.timeScale = -0.1;
}
break;
case "Digit1":
component.timeScale = 0.1;
console.log("Time scale: 0.1x (super slow)");
break;
case "Digit2":
component.timeScale = 0.25;
console.log("Time scale: 0.25x (slow)");
break;
case "Digit3":
component.timeScale = 0.5;
console.log("Time scale: 0.5x (half speed)");
break;
case "Digit4":
component.timeScale = 1.0;
console.log("Time scale: 1.0x (normal)");
break;
case "Digit5":
component.timeScale = 2.0;
console.log("Time scale: 2.0x (fast)");
break;
case "Digit6":
component.timeScale = 5.0;
console.log("Time scale: 5.0x (very fast)");
break;
}
}
The controls are intuitive:
- Spacebar: Pause/resume
- Right Arrow: Next frame (when paused)
- Left Arrow: Previous frame (when paused)
- Number Keys 1-6: Speed control from 0.1x to 5.0x
src/components/TimeControl.ts
public interceptDeltaTime(engineDeltaTime: number): number {
if (!this.timeControlComponent || !this.timeControlComponent.enabled) {
return engineDeltaTime;
}
const component = this.timeControlComponent;
component.actualDeltaTime = engineDeltaTime;
component.frameCount++;
if (component.isPaused) {
if (component.frameAdvanceRequested) {
component.frameAdvanceRequested = false;
component.modifiedDeltaTime = engineDeltaTime * component.timeScale;
this.updateDebugDisplay();
return component.modifiedDeltaTime;
} else {
component.modifiedDeltaTime = 0;
this.updateDebugDisplay();
return 0;
}
}
const scaledDelta = engineDeltaTime * component.timeScale;
const maxDelta = 1/30;
component.modifiedDeltaTime = Math.min(scaledDelta, maxDelta);
this.updateDebugDisplay();
return component.modifiedDeltaTime;
}
This is the heart of the system. The World's updateSystems() method calls interceptDeltaTime() instead of using the raw engine deltaTime. The method:
- Stores the original deltaTime for reference
- Returns 0 if paused (unless frame advance is requested)
- Applies time scaling for slow/fast motion
- Clamps extreme values to prevent physics explosions