Learn Game Dev Patterns

23 patterns across 8 categories. Each one shows the convention, a side-by-side example, and why it matters.

Start here

New to game development? Follow these five categories in order.

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

State Management
2 patterns

Finite state machines, scene transitions, save systems, and entity lifecycle. You'll hit this when adding a new enemy behavior breaks three others, or when your pause menu doesn't actually pause everything.

Avoid

Boolean flags (no validation)


Prefer

Finite state machine (validated transitions)

Input Handling
5 patterns

Input buffering, dead zones, action mapping, and frame-independent input. You'll hit this when jumps feel unresponsive, analog sticks drift, or rebinding keys requires a code change.

Avoid
document.addEventListener("keydown", (e) => {
  if (e.code === "Space") player.jump();
  if (e.code === "KeyX") player.attack();
  if (e.code === "ShiftLeft") player.dash();
  if (e.code === "KeyE") player.interact();
});

// Adding gamepad support means
// duplicating every binding
gamepad.onButtonPress(0, () => player.jump());
gamepad.onButtonPress(2, () => player.attack());
document.addEventListener("keydown", (e) => {
  if (e.code === "Space") player.jump();
  if (e.code === "KeyX") player.attack();
  if (e.code === "ShiftLeft") player.dash();
  if (e.code === "KeyE") player.interact();
});

// Adding gamepad support means
// duplicating every binding
gamepad.onButtonPress(0, () => player.jump());
gamepad.onButtonPress(2, () => player.attack());

Prefer
// Define actions, not keys
const actions = new ActionMap({
  jump:     [Key.Space, Pad.A],
  attack:   [Key.X, Pad.X],
  dash:     [Key.ShiftLeft, Pad.LB],
  interact: [Key.E, Pad.Y],
});

function update() {
  if (actions.justPressed("jump")) player.jump();
  if (actions.justPressed("attack")) player.attack();
  if (actions.justPressed("dash")) player.dash();
  if (actions.justPressed("interact")) player.interact();
}

// Rebinding is a data change, not a code change
actions.rebind("jump", Key.W);
// Define actions, not keys
const actions = new ActionMap({
  jump:     [Key.Space, Pad.A],
  attack:   [Key.X, Pad.X],
  dash:     [Key.ShiftLeft, Pad.LB],
  interact: [Key.E, Pad.Y],
});

function update() {
  if (actions.justPressed("jump")) player.jump();
  if (actions.justPressed("attack")) player.attack();
  if (actions.justPressed("dash")) player.dash();
  if (actions.justPressed("interact")) player.interact();
}

// Rebinding is a data change, not a code change
actions.rebind("jump", Key.W);
Physics & Collision
3 patterns

Collision detection, spatial partitioning, fixed timesteps, and continuous collision. You'll hit this when bullets pass through walls, collision checks tank your framerate, or physics behaves differently on fast machines.

Avoid

O(n²) pairwise checks


Prefer

Spatial hash: nearby checks only

Game AI
3 patterns

Behavior trees, finite state machines, pathfinding, and steering behaviors. You'll hit this when enemies get stuck on corners, AI decisions feel robotic, or adding a new behavior means rewriting the entire decision tree.

Avoid
function updateEnemy(enemy: Enemy, player: Player) {
  if (enemy.health < 20) {
    if (distanceTo(player) < 100) {
      flee(enemy, player);
    } else {
      heal(enemy);
    }
  } else if (distanceTo(player) < 50) {
    if (enemy.ammo > 0) {
      shoot(enemy, player);
    } else {
      melee(enemy, player);
    }
  } else if (distanceTo(player) < 200) {
    chase(enemy, player);
  } else {
    patrol(enemy);
  }
}
function updateEnemy(enemy: Enemy, player: Player) {
  if (enemy.health < 20) {
    if (distanceTo(player) < 100) {
      flee(enemy, player);
    } else {
      heal(enemy);
    }
  } else if (distanceTo(player) < 50) {
    if (enemy.ammo > 0) {
      shoot(enemy, player);
    } else {
      melee(enemy, player);
    }
  } else if (distanceTo(player) < 200) {
    chase(enemy, player);
  } else {
    patrol(enemy);
  }
}

Prefer
const enemyBehavior = selector([
  sequence([
    condition((e) => e.health < 20),
    selector([
      sequence([
        condition((e, p) => distanceTo(e, p) < 100),
        action(flee),
      ]),
      action(heal),
    ]),
  ]),
  sequence([
    condition((e, p) => distanceTo(e, p) < 50),
    selector([
      sequence([
        condition((e) => e.ammo > 0),
        action(shoot),
      ]),
      action(melee),
    ]),
  ]),
  sequence([
    condition((e, p) => distanceTo(e, p) < 200),
    action(chase),
  ]),
  action(patrol),
]);
const enemyBehavior = selector([
  sequence([
    condition((e) => e.health < 20),
    selector([
      sequence([
        condition((e, p) => distanceTo(e, p) < 100),
        action(flee),
      ]),
      action(heal),
    ]),
  ]),
  sequence([
    condition((e, p) => distanceTo(e, p) < 50),
    selector([
      sequence([
        condition((e) => e.ammo > 0),
        action(shoot),
      ]),
      action(melee),
    ]),
  ]),
  sequence([
    condition((e, p) => distanceTo(e, p) < 200),
    action(chase),
  ]),
  action(patrol),
]);
Rendering
2 patterns

Draw call batching, frustum culling, LOD, texture atlasing, and instanced rendering. You'll hit this when your scene drops below 60 FPS despite simple geometry, or when adding one more particle system halves your framerate.

Avoid
// One draw call per sprite
function render(sprites: Sprite[]) {
  for (const sprite of sprites) {
    gl.bindTexture(gl.TEXTURE_2D, sprite.texture);
    gl.uniformMatrix4fv(uModel, false, sprite.matrix);
    gl.drawElements(
      gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0,
    );
  }
}
// 1000 sprites = 1000 draw calls
// Each call has CPU overhead for
// state changes and driver validation
// One draw call per sprite
function render(sprites: Sprite[]) {
  for (const sprite of sprites) {
    gl.bindTexture(gl.TEXTURE_2D, sprite.texture);
    gl.uniformMatrix4fv(uModel, false, sprite.matrix);
    gl.drawElements(
      gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0,
    );
  }
}
// 1000 sprites = 1000 draw calls
// Each call has CPU overhead for
// state changes and driver validation

Prefer
// Batch sprites into one draw call
function render(sprites: Sprite[]) {
  // Sort by texture to minimize binds
  sprites.sort((a, b) => a.textureId - b.textureId);

  let currentTex = -1;
  let offset = 0;

  for (const sprite of sprites) {
    if (sprite.textureId !== currentTex) {
      if (offset > 0) flush(offset);
      gl.bindTexture(gl.TEXTURE_2D, sprite.texture);
      currentTex = sprite.textureId;
      offset = 0;
    }
    writeQuad(batchBuffer, offset, sprite);
    offset++;
  }
  if (offset > 0) flush(offset);
}
// 1000 sprites with 4 textures = 4 draw calls
// Batch sprites into one draw call
function render(sprites: Sprite[]) {
  // Sort by texture to minimize binds
  sprites.sort((a, b) => a.textureId - b.textureId);

  let currentTex = -1;
  let offset = 0;

  for (const sprite of sprites) {
    if (sprite.textureId !== currentTex) {
      if (offset > 0) flush(offset);
      gl.bindTexture(gl.TEXTURE_2D, sprite.texture);
      currentTex = sprite.textureId;
      offset = 0;
    }
    writeQuad(batchBuffer, offset, sprite);
    offset++;
  }
  if (offset > 0) flush(offset);
}
// 1000 sprites with 4 textures = 4 draw calls
Shaders
2 patterns

GPU branching, precision qualifiers, vertex vs fragment computation, and uniform batching. You'll hit this when a shader runs fine on desktop but crawls on mobile, or when a visual effect costs 10x more than it should.

Avoid

Phong shading (per-fragment)


Prefer

Gouraud shading (per-vertex)

Netcode
2 patterns

Client prediction, server authority, state interpolation, lag compensation, and snapshot compression. You'll hit this when players teleport, shots don't register, or the game feels unplayable above 100ms latency.

Avoid
// Client-authoritative: trust the client
// Client sends final position to server
function onPlayerMove(client: Client, data: MoveData) {
  client.player.x = data.x;
  client.player.y = data.y;
  client.player.health = data.health;
  broadcast(client.player);
}

// Client can send any position
// Client can set health to 9999
// Server has no way to detect cheating
// Client-authoritative: trust the client
// Client sends final position to server
function onPlayerMove(client: Client, data: MoveData) {
  client.player.x = data.x;
  client.player.y = data.y;
  client.player.health = data.health;
  broadcast(client.player);
}

// Client can send any position
// Client can set health to 9999
// Server has no way to detect cheating

Prefer
// Server-authoritative: server validates
// Client sends inputs, server simulates
function onPlayerInput(
  client: Client,
  input: InputData,
) {
  const player = client.player;
  const newPos = simulate(player, input);

  // Server validates the move
  if (isValidPosition(newPos)) {
    player.x = newPos.x;
    player.y = newPos.y;
  }

  broadcast(player);
}

// Client predicts locally for responsiveness
// Server corrects if prediction diverges
// Server-authoritative: server validates
// Client sends inputs, server simulates
function onPlayerInput(
  client: Client,
  input: InputData,
) {
  const player = client.player;
  const newPos = simulate(player, input);

  // Server validates the move
  if (isValidPosition(newPos)) {
    player.x = newPos.x;
    player.y = newPos.y;
  }

  broadcast(player);
}

// Client predicts locally for responsiveness
// Server corrects if prediction diverges