logo

Babylon.js Market

By Lawrence

7 minutes

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.

The Problem with Time

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.

The TimeControl Component: Your Temporal Remote

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

The TimeControl System: Master of Time

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));

    // Create debug display element
    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.

Keyboard Controls

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();
          // Step backwards by reversing time briefly
          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

The Time Interceptor

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++;

    // Handle pause state
    if (component.isPaused) {
      if (component.frameAdvanceRequested) {
        // Allow one frame to process
        component.frameAdvanceRequested = false;
        component.modifiedDeltaTime = engineDeltaTime * component.timeScale;
        this.updateDebugDisplay();
        return component.modifiedDeltaTime;
      } else {
        // Complete pause
        component.modifiedDeltaTime = 0;
        this.updateDebugDisplay();
        return 0;
      }
    }

    // Apply time scaling
    const scaledDelta = engineDeltaTime * component.timeScale;

    // Clamp to reasonable bounds
    const maxDelta = 1/30; // Don't allow frame times longer than 30fps
    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:

  1. Stores the original deltaTime for reference
  2. Returns 0 if paused (unless frame advance is requested)
  3. Applies time scaling for slow/fast motion
  4. Clamps extreme values to prevent physics explosions

Continue Reading

Unlock the full course to access all content and examples.

$8
↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search