Learn

/

Game Loop

Game Loop

4 patterns

Timesteps, update ordering, ECS vs inheritance, and object pooling. You'll hit this when physics breaks at low FPS, entities interact in unpredictable order, or garbage collection causes frame drops.

Avoid

Variable delta time


Prefer

Fixed timestep with accumulator

Why avoid

Passing the raw frame delta directly to update() ties game behavior to frame rate. At 30 FPS the delta is twice as large as at 60 FPS, which can cause tunneling in physics, inconsistent jump heights, and difficulty spikes on slower hardware. It also makes bugs nearly impossible to reproduce.

Why prefer

A fixed timestep decouples simulation from frame rate. Physics, AI, and gameplay logic receive the same delta every tick, making behavior deterministic and reproducible regardless of whether the game runs at 30 or 144 FPS. The accumulator pattern processes multiple fixed steps per frame when the machine is slow and skips none when it is fast.

Gaffer On Games: Fix Your Timestep!
Avoid

Camera snaps to character each frame


Prefer

Smooth follow: lerp factor 0.05

Why avoid

Snapping the camera to the player every frame keeps the character pixel-perfect centered, but the entire world jerks around with every movement change. This is especially noticeable on direction reversals and during fast movement. The rigid lock makes the scene feel mechanical and can cause motion discomfort in some players.

Why prefer

A dampened camera (lerping toward the target each frame) creates smooth, natural-feeling movement. The slight delay as the character drifts off-center during direction changes gives the player a sense of momentum and makes the world feel alive. Most 2D and 3D games use some form of smoothed follow, often with configurable lookahead and deadzone parameters.

Game Developer: Cameras in Side-Scrollers
Avoid
class Entity {
  x = 0; y = 0;
  health = 100;
  sprite: Sprite;

  update(dt: number) { /* ... */ }
  render(ctx: CanvasRenderingContext2D) { /* ... */ }
}

class Enemy extends Entity {
  ai: EnemyAI;
  update(dt: number) {
    super.update(dt);
    this.ai.think(dt);
  }
}

class FlyingEnemy extends Enemy {
  altitude = 0;
  update(dt: number) {
    super.update(dt);
    this.altitude += Math.sin(Date.now()) * dt;
  }
}
class Entity {
  x = 0; y = 0;
  health = 100;
  sprite: Sprite;

  update(dt: number) { /* ... */ }
  render(ctx: CanvasRenderingContext2D) { /* ... */ }
}

class Enemy extends Entity {
  ai: EnemyAI;
  update(dt: number) {
    super.update(dt);
    this.ai.think(dt);
  }
}

class FlyingEnemy extends Enemy {
  altitude = 0;
  update(dt: number) {
    super.update(dt);
    this.altitude += Math.sin(Date.now()) * dt;
  }
}

Prefer
// Components are plain data
interface Position { x: number; y: number }
interface Health { current: number; max: number }
interface Velocity { vx: number; vy: number }
interface Sprite { texture: string; frame: number }

// Systems operate on components
function movementSystem(
  dt: number,
  entities: [Position, Velocity][],
) {
  for (const [pos, vel] of entities) {
    pos.x += vel.vx * dt;
    pos.y += vel.vy * dt;
  }
}

function renderSystem(
  ctx: CanvasRenderingContext2D,
  entities: [Position, Sprite][],
) {
  for (const [pos, sprite] of entities) {
    draw(ctx, sprite, pos);
  }
}
// Components are plain data
interface Position { x: number; y: number }
interface Health { current: number; max: number }
interface Velocity { vx: number; vy: number }
interface Sprite { texture: string; frame: number }

// Systems operate on components
function movementSystem(
  dt: number,
  entities: [Position, Velocity][],
) {
  for (const [pos, vel] of entities) {
    pos.x += vel.vx * dt;
    pos.y += vel.vy * dt;
  }
}

function renderSystem(
  ctx: CanvasRenderingContext2D,
  entities: [Position, Sprite][],
) {
  for (const [pos, sprite] of entities) {
    draw(ctx, sprite, pos);
  }
}
Why avoid

Deep inheritance hierarchies couple data and behavior tightly. Adding a FlyingEnemy that also needs networking means choosing between duplicating code or creating fragile diamond-shaped hierarchies. The deeper the tree, the harder it is to override behavior without breaking parent assumptions, and the more likely you are to pull in unused state.

Why prefer

Entity Component System (ECS) separates data (components) from behavior (systems). Adding a new capability means attaching a component, not creating a new subclass. Systems process all entities with a given component set, which keeps logic flat, cache-friendly, and easy to compose. A flying enemy is just an entity with Position, Velocity, and Hover components.

GameDev.net: Understanding ECS
Avoid
function spawnBullet(x: number, y: number) {
  const bullet = new Bullet(x, y);
  bullets.push(bullet);
}

function update(dt: number) {
  for (const bullet of bullets) {
    bullet.update(dt);
  }
  // Remove dead bullets, creating garbage
  bullets = bullets.filter((b) => b.alive);
}
function spawnBullet(x: number, y: number) {
  const bullet = new Bullet(x, y);
  bullets.push(bullet);
}

function update(dt: number) {
  for (const bullet of bullets) {
    bullet.update(dt);
  }
  // Remove dead bullets, creating garbage
  bullets = bullets.filter((b) => b.alive);
}

Prefer
const pool: Bullet[] = [];
let activeCount = 0;

function spawnBullet(x: number, y: number) {
  let bullet: Bullet;
  if (activeCount < pool.length) {
    bullet = pool[activeCount];
    bullet.reset(x, y);
  } else {
    bullet = new Bullet(x, y);
    pool.push(bullet);
  }
  activeCount++;
}

function update(dt: number) {
  for (let i = 0; i < activeCount; i++) {
    pool[i].update(dt);
    if (!pool[i].alive) {
      // Swap with last active
      [pool[i], pool[activeCount - 1]] =
        [pool[activeCount - 1], pool[i]];
      activeCount--;
      i--;
    }
  }
}
const pool: Bullet[] = [];
let activeCount = 0;

function spawnBullet(x: number, y: number) {
  let bullet: Bullet;
  if (activeCount < pool.length) {
    bullet = pool[activeCount];
    bullet.reset(x, y);
  } else {
    bullet = new Bullet(x, y);
    pool.push(bullet);
  }
  activeCount++;
}

function update(dt: number) {
  for (let i = 0; i < activeCount; i++) {
    pool[i].update(dt);
    if (!pool[i].alive) {
      // Swap with last active
      [pool[i], pool[activeCount - 1]] =
        [pool[activeCount - 1], pool[i]];
      activeCount--;
      i--;
    }
  }
}
Why avoid

Allocating a new object per spawn and filtering the array every frame generates garbage that the GC must eventually collect. In JavaScript and similar managed runtimes, GC pauses are unpredictable and can cause frame drops at the worst possible moment. The filter() call also allocates a new array every frame.

Why prefer

Object pooling pre-allocates and recycles objects instead of creating and discarding them every frame. In a bullet-hell scenario with hundreds of projectiles spawning per second, pooling avoids garbage collection pauses that cause visible frame stutters. The swap-with-last trick keeps active objects contiguous for cache-friendly iteration.

Game Programming Patterns: Object Pool