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
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.
Variable delta time
Fixed timestep with accumulator
State Management
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.
Boolean flags (no validation)
Finite state machine (validated transitions)
Input Handling
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.
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());// 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
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.
O(n²) pairwise checks
Spatial hash: nearby checks only
Game AI
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.
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);
}
}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
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.
// 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// 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 callsShaders
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.
Phong shading (per-fragment)
Gouraud shading (per-vertex)
Netcode
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.
// 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// 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