game-loop

SKILL.md

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)

Implementation

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

// 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

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)
Weekly Installs
17
GitHub Stars
761
First Seen
Jan 25, 2026
Installed on
codex17
opencode16
github-copilot16
cursor16
gemini-cli15
claude-code14