game-loop

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Fixed Timestep Game Loop

固定时间步游戏循环

Frame-rate independent game loop with physics interpolation and time manipulation.
具备物理插值和时间操控功能的帧率无关游戏循环。

When to Use This Skill

何时使用该方案

  • Building browser-based games or interactive simulations
  • Need consistent physics regardless of monitor refresh rate
  • Want smooth rendering with deterministic game logic
  • Implementing hitstop, slow-mo, or time manipulation effects
  • 开发基于浏览器的游戏或交互式模拟
  • 需要不受显示器刷新率影响的稳定物理效果
  • 想要结合确定性游戏逻辑的流畅渲染
  • 实现击中停顿、慢动作或时间操控效果

Core Concepts

核心概念

The key insight is separating physics (fixed timestep) from rendering (variable). An accumulator tracks time debt, running physics at a consistent rate while interpolating between states for smooth visuals.
Frame → Accumulator += delta → While(accumulator >= fixedStep) { physics() } → Render(interpolation)
关键思路是将物理更新(固定时间步)与渲染(可变帧率)分离。累加器跟踪时间差值,以稳定速率运行物理逻辑,同时通过状态插值实现流畅视觉效果。
Frame → Accumulator += delta → While(accumulator >= fixedStep) { physics() } → Render(interpolation)

Implementation

实现方案

TypeScript

TypeScript

typescript
interface GameLoopStats {
  fps: number;
  frameTime: number;
  physicsTime: number;
  renderTime: number;
  lagSpikes: number;
  interpolation: number;
  timeScale: number;
  isInHitstop: boolean;
}

interface GameLoopCallbacks {
  onFixedUpdate: (fixedDelta: number, now: number) => void;
  onRenderUpdate: (delta: number, interpolation: number, now: number) => void;
  onLagSpike?: (missedFrames: number) => void;
}

class GameLoop {
  private fixedTimestep: number;
  private readonly MAX_FRAME_TIME = 0.25;
  
  private accumulator = 0;
  private lastTime = 0;
  private interpolation = 0;
  
  private frameCount = 0;
  private fpsTimer = 0;
  private currentFps = 60;
  private lagSpikes = 0;
  
  private running = false;
  private animationId: number | null = null;
  private callbacks: GameLoopCallbacks;
  
  private hitstopTimer = 0;
  private hitstopIntensity = 0;
  private externalTimeScale = 1.0;

  constructor(callbacks: GameLoopCallbacks, fixedTimestep = 1 / 60) {
    this.callbacks = callbacks;
    this.fixedTimestep = fixedTimestep;
  }

  start(): void {
    if (this.running) return;
    this.running = true;
    this.lastTime = performance.now() / 1000;
    this.accumulator = 0;
    this.loop();
  }

  stop(): void {
    this.running = false;
    if (this.animationId !== null) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }

  triggerHitstop(frames = 3, intensity = 0.1): void {
    this.hitstopTimer = frames * this.fixedTimestep;
    this.hitstopIntensity = intensity;
  }

  setTimeScale(scale: number): void {
    this.externalTimeScale = Math.max(0, scale);
  }

  getStats(): GameLoopStats {
    return {
      fps: this.currentFps,
      frameTime: 0,
      physicsTime: 0,
      renderTime: 0,
      lagSpikes: this.lagSpikes,
      interpolation: this.interpolation,
      timeScale: this.getEffectiveTimeScale(),
      isInHitstop: this.hitstopTimer > 0,
    };
  }

  private loop = (): void => {
    if (!this.running) return;

    const now = performance.now() / 1000;
    let frameTime = now - this.lastTime;
    this.lastTime = now;

    // Cap frame time to prevent spiral of death
    if (frameTime > this.MAX_FRAME_TIME) {
      const missedFrames = Math.floor(frameTime / this.fixedTimestep);
      this.lagSpikes++;
      this.callbacks.onLagSpike?.(missedFrames);
      frameTime = this.MAX_FRAME_TIME;
    }

    frameTime *= this.getEffectiveTimeScale();

    if (this.hitstopTimer > 0) {
      this.hitstopTimer -= frameTime / this.getEffectiveTimeScale();
    }

    this.accumulator += frameTime;

    // Fixed timestep physics
    while (this.accumulator >= this.fixedTimestep) {
      this.callbacks.onFixedUpdate(this.fixedTimestep, now);
      this.accumulator -= this.fixedTimestep;
    }

    // Interpolation for smooth rendering
    this.interpolation = this.accumulator / this.fixedTimestep;
    this.callbacks.onRenderUpdate(frameTime, this.interpolation, now);

    // FPS calculation
    this.frameCount++;
    this.fpsTimer += frameTime / this.getEffectiveTimeScale();
    if (this.fpsTimer >= 1.0) {
      this.currentFps = Math.round(this.frameCount / this.fpsTimer);
      this.frameCount = 0;
      this.fpsTimer = 0;
    }

    this.animationId = requestAnimationFrame(this.loop);
  };

  private getEffectiveTimeScale(): number {
    return this.hitstopTimer > 0 ? this.hitstopIntensity : this.externalTimeScale;
  }
}

// Interpolation helpers
function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

function lerpAngle(a: number, b: number, t: number): number {
  let diff = b - a;
  while (diff > Math.PI) diff -= Math.PI * 2;
  while (diff < -Math.PI) diff += Math.PI * 2;
  return a + diff * t;
}
typescript
interface GameLoopStats {
  fps: number;
  frameTime: number;
  physicsTime: number;
  renderTime: number;
  lagSpikes: number;
  interpolation: number;
  timeScale: number;
  isInHitstop: boolean;
}

interface GameLoopCallbacks {
  onFixedUpdate: (fixedDelta: number, now: number) => void;
  onRenderUpdate: (delta: number, interpolation: number, now: number) => void;
  onLagSpike?: (missedFrames: number) => void;
}

class GameLoop {
  private fixedTimestep: number;
  private readonly MAX_FRAME_TIME = 0.25;
  
  private accumulator = 0;
  private lastTime = 0;
  private interpolation = 0;
  
  private frameCount = 0;
  private fpsTimer = 0;
  private currentFps = 60;
  private lagSpikes = 0;
  
  private running = false;
  private animationId: number | null = null;
  private callbacks: GameLoopCallbacks;
  
  private hitstopTimer = 0;
  private hitstopIntensity = 0;
  private externalTimeScale = 1.0;

  constructor(callbacks: GameLoopCallbacks, fixedTimestep = 1 / 60) {
    this.callbacks = callbacks;
    this.fixedTimestep = fixedTimestep;
  }

  start(): void {
    if (this.running) return;
    this.running = true;
    this.lastTime = performance.now() / 1000;
    this.accumulator = 0;
    this.loop();
  }

  stop(): void {
    this.running = false;
    if (this.animationId !== null) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }

  triggerHitstop(frames = 3, intensity = 0.1): void {
    this.hitstopTimer = frames * this.fixedTimestep;
    this.hitstopIntensity = intensity;
  }

  setTimeScale(scale: number): void {
    this.externalTimeScale = Math.max(0, scale);
  }

  getStats(): GameLoopStats {
    return {
      fps: this.currentFps,
      frameTime: 0,
      physicsTime: 0,
      renderTime: 0,
      lagSpikes: this.lagSpikes,
      interpolation: this.interpolation,
      timeScale: this.getEffectiveTimeScale(),
      isInHitstop: this.hitstopTimer > 0,
    };
  }

  private loop = (): void => {
    if (!this.running) return;

    const now = performance.now() / 1000;
    let frameTime = now - this.lastTime;
    this.lastTime = now;

    // Cap frame time to prevent spiral of death
    if (frameTime > this.MAX_FRAME_TIME) {
      const missedFrames = Math.floor(frameTime / this.fixedTimestep);
      this.lagSpikes++;
      this.callbacks.onLagSpike?.(missedFrames);
      frameTime = this.MAX_FRAME_TIME;
    }

    frameTime *= this.getEffectiveTimeScale();

    if (this.hitstopTimer > 0) {
      this.hitstopTimer -= frameTime / this.getEffectiveTimeScale();
    }

    this.accumulator += frameTime;

    // Fixed timestep physics
    while (this.accumulator >= this.fixedTimestep) {
      this.callbacks.onFixedUpdate(this.fixedTimestep, now);
      this.accumulator -= this.fixedTimestep;
    }

    // Interpolation for smooth rendering
    this.interpolation = this.accumulator / this.fixedTimestep;
    this.callbacks.onRenderUpdate(frameTime, this.interpolation, now);

    // FPS calculation
    this.frameCount++;
    this.fpsTimer += frameTime / this.getEffectiveTimeScale();
    if (this.fpsTimer >= 1.0) {
      this.currentFps = Math.round(this.frameCount / this.fpsTimer);
      this.frameCount = 0;
      this.fpsTimer = 0;
    }

    this.animationId = requestAnimationFrame(this.loop);
  };

  private getEffectiveTimeScale(): number {
    return this.hitstopTimer > 0 ? this.hitstopIntensity : this.externalTimeScale;
  }
}

// Interpolation helpers
function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

function lerpAngle(a: number, b: number, t: number): number {
  let diff = b - a;
  while (diff > Math.PI) diff -= Math.PI * 2;
  while (diff < -Math.PI) diff += Math.PI * 2;
  return a + diff * t;
}

Usage Examples

使用示例

typescript
// Game state
let playerX = 0, playerY = 0;
let playerVelX = 0, playerVelY = 0;
let prevPlayerX = 0, prevPlayerY = 0;

const gameLoop = new GameLoop({
  onFixedUpdate: (fixedDelta) => {
    // Store previous for interpolation
    prevPlayerX = playerX;
    prevPlayerY = playerY;

    // Deterministic physics
    playerVelY += 980 * fixedDelta; // Gravity
    playerX += playerVelX * fixedDelta;
    playerY += playerVelY * fixedDelta;

    // Collision
    if (playerY > 500) {
      playerY = 500;
      playerVelY = 0;
    }
  },

  onRenderUpdate: (delta, interpolation) => {
    // Smooth rendering between physics states
    const renderX = lerp(prevPlayerX, playerX, interpolation);
    const renderY = lerp(prevPlayerY, playerY, interpolation);

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillRect(renderX - 10, renderY - 10, 20, 20);
  },

  onLagSpike: (missed) => console.warn(`Lag: missed ${missed} frames`),
});

gameLoop.start();

// Hitstop on collision
function onPlayerHit() {
  gameLoop.triggerHitstop(4, 0.05); // 4 frames at 5% speed
}

// Slow-mo death
function onPlayerDeath() {
  gameLoop.setTimeScale(0.3);
  setTimeout(() => gameLoop.setTimeScale(1.0), 2000);
}
typescript
// Game state
let playerX = 0, playerY = 0;
let playerVelX = 0, playerVelY = 0;
let prevPlayerX = 0, prevPlayerY = 0;

const gameLoop = new GameLoop({
  onFixedUpdate: (fixedDelta) => {
    // Store previous for interpolation
    prevPlayerX = playerX;
    prevPlayerY = playerY;

    // Deterministic physics
    playerVelY += 980 * fixedDelta; // Gravity
    playerX += playerVelX * fixedDelta;
    playerY += playerVelY * fixedDelta;

    // Collision
    if (playerY > 500) {
      playerY = 500;
      playerVelY = 0;
    }
  },

  onRenderUpdate: (delta, interpolation) => {
    // Smooth rendering between physics states
    const renderX = lerp(prevPlayerX, playerX, interpolation);
    const renderY = lerp(prevPlayerY, playerY, interpolation);

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillRect(renderX - 10, renderY - 10, 20, 20);
  },

  onLagSpike: (missed) => console.warn(`Lag: missed ${missed} frames`),
});

gameLoop.start();

// Hitstop on collision
function onPlayerHit() {
  gameLoop.triggerHitstop(4, 0.05); // 4 frames at 5% speed
}

// Slow-mo death
function onPlayerDeath() {
  gameLoop.setTimeScale(0.3);
  setTimeout(() => gameLoop.setTimeScale(1.0), 2000);
}

Best Practices

最佳实践

  1. Always store previous state before physics update for interpolation
  2. Cap frame time to prevent spiral of death (0.25s is reasonable)
  3. Use fixed timestep for all game logic, variable only for rendering
  4. Tune hitstop values for game feel (2-5 frames typical)
  5. Consider 30Hz physics for mobile to save CPU
  1. 物理更新前始终存储上一状态用于插值
  2. 限制帧时间以防止「死亡螺旋」(0.25秒是合理值)
  3. 所有游戏逻辑使用固定时间步,仅渲染使用可变帧率
  4. 根据游戏体验调整击中停顿参数(通常2-5帧)
  5. 移动端考虑使用30Hz物理更新以节省CPU

Common Mistakes

常见错误

  • Running physics in render callback (frame-rate dependent)
  • Not interpolating positions (causes stuttering)
  • Forgetting to cap frame time (causes spiral of death on tab switch)
  • Using delta time for physics (non-deterministic)
  • 在渲染回调中运行物理逻辑(依赖帧率)
  • 不对位置进行插值(导致卡顿)
  • 忘记限制帧时间(切换标签页时引发「死亡螺旋」)
  • 为物理逻辑使用增量时间(非确定性)

Related Patterns

相关模式

  • server-tick (server-side equivalent)
  • websocket-management (multiplayer sync)
  • server-tick(服务端等效方案)
  • websocket-management(多人同步)