Game Design Patterns: Architecting Better Games 🏗️
Welcome to our guide on game design patterns for ARCD developers! If you've ever struggled with code that becomes unmanageable as your game grows, or found yourself rewriting the same systems for each new project, this tutorial is for you.
Design patterns are battle-tested solutions to common programming challenges. They provide structured approaches to organizing your code, making it more modular, maintainable, and scalable. This guide explores essential patterns specifically adapted for game development, with practical JavaScript/TypeScript examples you can use in your own ARCD projects.
Let's build games that aren't just fun to play, but also a joy to develop! 🎮
Component-Based Architecture: Building Blocks 🧩
Component-based architecture is the foundation of modern game development, replacing deep inheritance hierarchies with flexible composition.
The Problem with Inheritance
Traditional game architecture often uses inheritance to define game objects:
class GameObject { /* ... */ }
class Character extends GameObject { /* ... */ }
class Player extends Character { /* ... */ }
class Enemy extends Character { /* ... */ }
class FlyingEnemy extends Enemy { /* ... */ }
class SwimmingEnemy extends Enemy { /* ... */ }
This quickly becomes problematic when you need entities that don't fit neatly in the hierarchy. What about a swimming enemy that can also fly? Or a player who temporarily gains enemy abilities?
Component-Based Solution
Instead, break functionality into composable components:
class Entity {
constructor() {
this.components = new Map();
}
addComponent(component) {
component.entity = this;
this.components.set(component.constructor.name, component);
return this;
}
getComponent(componentClass) {
return this.components.get(componentClass.name);
}
removeComponent(componentClass) {
const component = this.components.get(componentClass.name);
if (component) {
component.entity = null;
this.components.delete(componentClass.name);
}
return this;
}
update(deltaTime) {
for (const component of this.components.values()) {
if (component.update) {
component.update(deltaTime);
}
}
}
}
// Define components
class PositionComponent {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
}
class RenderComponent {
constructor(sprite) {
this.sprite = sprite;
}
update() {
const position = this.entity.getComponent(PositionComponent);
if (position) {
this.sprite.x = position.x;
this.sprite.y = position.y;
}
}
}
class PhysicsComponent {
constructor(velocityX = 0, velocityY = 0) {
this.velocityX = velocityX;
this.velocityY = velocityY;
}
update(deltaTime) {
const position = this.entity.getComponent(PositionComponent);
if (position) {
position.x += this.velocityX * deltaTime;
position.y += this.velocityY * deltaTime;
}
}
}
// Create a player entity
const player = new Entity()
.addComponent(new PositionComponent(100, 100))
.addComponent(new RenderComponent(playerSprite))
.addComponent(new PhysicsComponent());
// Create an enemy entity with different components
const enemy = new Entity()
.addComponent(new PositionComponent(200, 50))
.addComponent(new RenderComponent(enemySprite))
.addComponent(new PhysicsComponent())
.addComponent(new AIComponent());
Benefits of Component Architecture:
- Flexibility: Compose entities with exactly the behaviors they need
- Reusability: Components can be shared across different entity types
- Maintainability: Modify a single component without affecting others
- Testability: Components can be tested in isolation
Real-World Example:
Modern game engines like Unity, Unreal, and frameworks like Phaser 3 all use component-based architectures. In Phaser 3, this is represented by Game Objects and their components:
const player = this.add.sprite(100, 100, 'player'); // Create a sprite
player.setInteractive(); // Add input component
player.setBounce(0.2); // Add physics component
player.setCollideWorldBounds(true); // Add collision component
this.physics.add.existing(player); // Add physics body
State Management Patterns: Controlling Game Flow 🚦
Games are complex state machines, constantly transitioning between different states (menu, playing, paused, game over, etc.). Proper state management is crucial for maintainable game logic.
The State Pattern
The State pattern allows an object to alter its behavior when its internal state changes:
// Define the state interface
class PlayerState {
constructor(player) {
this.player = player;
}
enter() {} // Called when entering this state
update() {} // Called every frame
exit() {} // Called when leaving this state
// Input handlers
handleJumpInput() {}
handleAttackInput() {}
}
// Concrete states
class IdleState extends PlayerState {
enter() {
this.player.setAnimation('idle');
}
update() {
// Check for movement input
if (this.player.isMoving()) {
this.player.changeState(new RunningState(this.player));
}
}
handleJumpInput() {
this.player.changeState(new JumpingState(this.player));
}
handleAttackInput() {
this.player.changeState(new AttackingState(this.player));
}
}
class RunningState extends PlayerState {
enter() {
this.player.setAnimation('run');
}
update() {
// Check if no longer moving
if (!this.player.isMoving()) {
this.player.changeState(new IdleState(this.player));
}
}
handleJumpInput() {
this.player.changeState(new JumpingState(this.player));
}
}
class JumpingState extends PlayerState {
enter() {
this.player.setAnimation('jump');
this.player.velocity.y = -15; // Jump force
}
update() {
// Check if landed
if (this.player.isOnGround()) {
if (this.player.isMoving()) {
this.player.changeState(new RunningState(this.player));
} else {
this.player.changeState(new IdleState(this.player));
}
}
}
// Can't jump again while already jumping
handleJumpInput() {}
}
// Player class that uses states
class Player {
constructor() {
this.state = new IdleState(this);
this.velocity = { x: 0, y: 0 };
// ... other player properties
}
changeState(newState) {
this.state.exit();
this.state = newState;
this.state.enter();
}
update() {
this.state.update();
// ... update physics, position, etc.
}
// Input handlers delegate to the current state
jump() {
this.state.handleJumpInput();
}
attack() {
this.state.handleAttackInput();
}
// Helper methods
isMoving() {
return Math.abs(this.velocity.x) > 0.1;
}
isOnGround() {
// Check collision with ground
return this.position.y >= groundLevel;
}
setAnimation(name) {
// Logic to change sprite animation
this.currentAnimation = name;
}
}
Finite State Machines (FSMs)
For more complex state management, you might want a dedicated FSM implementation:
class FiniteStateMachine {
constructor(initialState) {
this.states = {};
this.currentState = null;
if (initialState) {
this.setState(initialState);
}
}
addState(name, state) {
this.states[name] = state;
return this;
}
setState(name) {
if (this.currentState && this.currentState.exit) {
this.currentState.exit();
}
this.currentState = this.states[name];
if (this.currentState && this.currentState.enter) {
this.currentState.enter();
}
return this;
}
update(deltaTime) {
if (this.currentState && this.currentState.update) {
this.currentState.update(deltaTime);
}
}
}
// Example game state machine
const gameStateMachine = new FiniteStateMachine()
.addState('menu', {
enter: () => {
showMainMenu();
playMenuMusic();
},
update: () => {
// Update menu animations
},
exit: () => {
hideMainMenu();
stopMenuMusic();
}
})
.addState('playing', {
enter: () => {
setupLevel();
playGameMusic();
},
update: (dt) => {
updateEntities(dt);
checkCollisions();
updateScore();
},
exit: () => {
cleanupLevel();
}
})
.addState('paused', {
enter: () => {
showPauseMenu();
dimGameScreen();
},
exit: () => {
hidePauseMenu();
undimGameScreen();
}
})
.addState('gameOver', {
enter: () => {
showGameOverScreen();
playGameOverMusic();
}
});
// Start with the menu state
gameStateMachine.setState('menu');
// Update on each frame
function gameLoop(time) {
const deltaTime = time - lastTime;
lastTime = time;
gameStateMachine.update(deltaTime);
requestAnimationFrame(gameLoop);
}
State-Based Triggers and Events
States can respond to events or trigger transitions:
class GameLevel {
constructor() {
this.stateMachine = new FiniteStateMachine()
.addState('intro', {
enter: () => {
this.playIntroSequence();
},
update: () => {
if (this.introSequence.isComplete()) {
this.stateMachine.setState('playing');
}
}
})
.addState('playing', {
update: () => {
if (this.checkVictoryCondition()) {
this.stateMachine.setState('victory');
} else if (this.player.health <= 0) {
this.stateMachine.setState('defeat');
}
}
})
.addState('victory', {
enter: () => {
this.playVictorySequence();
this.unlockNextLevel();
}
})
.addState('defeat', {
enter: () => {
this.playDefeatSequence();
setTimeout(() => {
this.stateMachine.setState('restart');
}, 3000);
}
})
.addState('restart', {
enter: () => {
this.resetLevel();
this.stateMachine.setState('intro');
}
});
this.stateMachine.setState('intro');
}
}
When to Use State Management:
- ✅ Complex character behavior (player states, enemy AI states)
- ✅ Game flow control (menus, levels, game modes)
- ✅ UI screens and transitions
- ✅ Multi-step game mechanics (powerup sequences, combos)
Observer Pattern: Event-Driven Architecture 📡
The Observer pattern creates a subscription model where objects (observers) are notified of changes in other objects (subjects). This is perfect for games where many systems need to respond to events without tight coupling.
Basic Implementation
class EventEmitter {
constructor() {
this.listeners = new Map();
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return this; // For chaining
}
off(event, callback) {
if (!this.listeners.has(event)) return this;
const eventListeners = this.listeners.get(event);
const index = eventListeners.indexOf(callback);
if (index !== -1) {
eventListeners.splice(index, 1);
}
return this;
}
emit(event, ...args) {
if (!this.listeners.has(event)) return this;
for (const callback of this.listeners.get(event)) {
callback(...args);
}
return this;
}
once(event, callback) {
const onceCallback = (...args) => {
callback(...args);
this.off(event, onceCallback);
};
return this.on(event, onceCallback);
}
}
// Global game events
const gameEvents = new EventEmitter();
// Player can emit and listen to events
class Player extends EventEmitter {
constructor() {
super();
this.health = 100;
// Listen for global game events
gameEvents.on('gameOver', () => {
this.stopMovement();
this.playDeathAnimation();
});
}
takeDamage(amount) {
this.health -= amount;
// Emit event with data
this.emit('healthChanged', this.health);
if (this.health <= 0) {
this.emit('died');
gameEvents.emit('playerDied', this);
}
}
}
// UI that responds to player events
class HealthBar {
constructor(player) {
this.player = player;
this.element = document.getElementById('health-bar');
// Subscribe to player events
player.on('healthChanged', (health) => {
this.updateBar(health);
});
}
updateBar(health) {
const percentage = Math.max(0, health) + '%';
this.element.style.width = percentage;
if (health < 20) {
this.element.classList.add('critical');
} else {
this.element.classList.remove('critical');
}
}
}
// Achievement system listening for events
class AchievementSystem {
constructor() {
this.unlockedAchievements = new Set();
// Listen for various game events
gameEvents.on('enemyDefeated', (enemy) => {
if (enemy.type === 'boss') {
this.unlockAchievement('boss-slayer');
}
});
gameEvents.on('playerDied', () => {
this.unlockAchievement('first-death');
});
gameEvents.on('levelCompleted', (level) => {
if (level.noHits) {
this.unlockAchievement('perfect-run');
}
});
}
unlockAchievement(id) {
if (!this.unlockedAchievements.has(id)) {
this.unlockedAchievements.add(id);
gameEvents.emit('achievementUnlocked', id);
// Show notification, update UI, etc.
}
}
}
Benefits of Observer Pattern:
- Decoupling: Systems can communicate without direct references to each other
- Extensibility: New observers can be added without modifying existing code
- Maintainability: Easier to debug and test components in isolation
- Scalability: Elegant for complex games with many interacting systems
Practical Game Uses:
- Health/resource changes → UI updates
- Player actions → Achievement tracking
- Enemy defeated → Score updates, item drops
- Time changes → Day/night cycle effects
- Collision events → Sound effects, particle systems
- Game state changes → Music transitions, UI changes
Singleton & Service Locator Patterns: Managing Global Resources 🌍
The Singleton pattern ensures a class has only one instance and provides a global point of access to it. While overusing singletons can lead to tight coupling, they're useful for managing global game resources or systems.
Basic Singleton
class GameManager {
constructor() {
// Throw error if instance already exists
if (GameManager.instance) {
throw new Error("GameManager already exists! Use GameManager.getInstance()");
}
this.score = 0;
this.level = 1;
this.gameTime = 0;
// Save instance
GameManager.instance = this;
}
static getInstance() {
if (!GameManager.instance) {
GameManager.instance = new GameManager();
}
return GameManager.instance;
}
addScore(points) {
this.score += points;
gameEvents.emit('scoreChanged', this.score);
}
nextLevel() {
this.level++;
gameEvents.emit('levelChanged', this.level);
}
update(deltaTime) {
this.gameTime += deltaTime;
}
resetGame() {
this.score = 0;
this.level = 1;
this.gameTime = 0;
}
}
// Usage
function handleEnemyDefeat(enemy) {
// Access the singleton from anywhere
const gameManager = GameManager.getInstance();
gameManager.addScore(enemy.pointValue);
}
Service Locator Pattern
A more flexible approach is the Service Locator pattern, which allows you to register and retrieve game services dynamically:
class ServiceLocator {
constructor() {
this.services = new Map();
}
register(serviceType, implementation) {
this.services.set(serviceType, implementation);
}
get(serviceType) {
const service = this.services.get(serviceType);
if (!service) {
throw new Error(`Service ${serviceType} not registered!`);
}
return service;
}
unregister(serviceType) {
this.services.delete(serviceType);
}
}
// Create global service locator
const gameServices = new ServiceLocator();
// Create and register services
class AudioService {
constructor() {
this.sounds = new Map();
this.currentMusic = null;
}
playSound(id, volume = 1.0) {
// Sound playing logic
}
playMusic(id, fadeIn = 0.5) {
// Music playing logic
}
}
class SaveService {
constructor() {
this.saveData = {};
}
saveGame() {
// Save game logic
}
loadGame() {
// Load game logic
}
}
// Register services
gameServices.register('audio', new AudioService());
gameServices.register('save', new SaveService());
// Usage in game code
function handleVictory() {
// Get audio service
const audioService = gameServices.get('audio');
audioService.playSound('victory-fanfare');
// Get save service
const saveService = gameServices.get('save');
saveService.saveGame();
}
When to Use Singleton/Service Locator:
- ✅ Managing global resources (audio, input, assets)
- ✅ Coordinating systems that need global access (save/load, achievements)
- ✅ When you genuinely need exactly one instance of a system
When to Avoid:
- ❌ For most game entities (use component architecture instead)
- ❌ When tight coupling would make testing difficult
- ❌ When the system could benefit from having multiple instances
Command Pattern: Actions as Objects 🎮
The Command pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue requests, and support undoable operations.
Basic Implementation
// Command interface
class Command {
execute() {}
undo() {} // Optional for reversible commands
}
// Concrete commands
class MoveCommand extends Command {
constructor(entity, dx, dy) {
super();
this.entity = entity;
this.dx = dx;
this.dy = dy;
this.oldX = 0;
this.oldY = 0;
}
execute() {
const position = this.entity.getComponent(PositionComponent);
if (position) {
this.oldX = position.x;
this.oldY = position.y;
position.x += this.dx;
position.y += this.dy;
}
}
undo() {
const position = this.entity.getComponent(PositionComponent);
if (position) {
position.x = this.oldX;
position.y = this.oldY;
}
}
}
class AttackCommand extends Command {
constructor(entity, target) {
super();
this.entity = entity;
this.target = target;
}
execute() {
const combat = this.entity.getComponent(CombatComponent);
if (combat) {
combat.attack(this.target);
}
}
// No undo - can't undo an attack
}
// Command processor / invoker
class InputHandler {
constructor() {
this.commands = {};
this.history = [];
}
bindCommand(key, command) {
this.commands[key] = command;
}
handleInput(key) {
if (this.commands[key]) {
const command = this.commands[key];
command.execute();
this.history.push(command);
}
}
undoLastCommand() {
if (this.history.length > 0) {
const command = this.history.pop();
if (command.undo) {
command.undo();
} else {
console.log("Command cannot be undone");
}
}
}
}
// Usage
const player = new Entity()
.addComponent(new PositionComponent(100, 100))
.addComponent(new CombatComponent());
const inputHandler = new InputHandler();
// Bind commands to keys
inputHandler.bindCommand('w', new MoveCommand(player, 0, -10));
inputHandler.bindCommand('a', new MoveCommand(player, -10, 0));
inputHandler.bindCommand('s', new MoveCommand(player, 0, 10));
inputHandler.bindCommand('d', new MoveCommand(player, 10, 0));
inputHandler.bindCommand('space', new AttackCommand(player, nearestEnemy));
// In game loop
document.addEventListener('keydown', (e) => {
inputHandler.handleInput(e.key);
});
// Undo button
undoButton.addEventListener('click', () => {
inputHandler.undoLastCommand();
});
Replay System
Commands are great for implementing replay and undo systems:
class GameReplay {
constructor() {
this.commandLog = [];
this.isRecording = false;
this.isReplaying = false;
}
startRecording() {
this.commandLog = [];
this.isRecording = true;
}
stopRecording() {
this.isRecording = false;
return [...this.commandLog]; // Return a copy of recorded commands
}
recordCommand(command) {
if (this.isRecording) {
this.commandLog.push({
commandType: command.constructor.name,
params: command.serialize(), // Command-specific data
timestamp: performance.now()
});
}
}
replay(commandLog, speed = 1.0) {
this.isReplaying = true;
const startTime = performance.now();
// Process each command at its recorded time
commandLog.forEach(entry => {
const delay = (entry.timestamp - startTime) / speed;
setTimeout(() => {
// Recreate and execute command
const command = this.createCommandFromLog(entry);
if (command) {
command.execute();
}
}, delay);
});
// Calculate total replay duration
const lastCommand = commandLog[commandLog.length - 1];
const replayDuration = (lastCommand.timestamp - startTime) / speed;
// End replay
setTimeout(() => {
this.isReplaying = false;
}, replayDuration);
}
createCommandFromLog(logEntry) {
// Factory method to recreate commands from log data
switch (logEntry.commandType) {
case 'MoveCommand':
return new MoveCommand(
getEntityById(logEntry.params.entityId),
logEntry.params.dx,
logEntry.params.dy
);
case 'AttackCommand':
return new AttackCommand(
getEntityById(logEntry.params.entityId),
getEntityById(logEntry.params.targetId)
);
// Add cases for other command types
default:
console.error(`Unknown command type: ${logEntry.commandType}`);
return null;
}
}
}
When to Use Command Pattern:
- ✅ Input handling with rebindable controls
- ✅ Implementing undo/redo functionality
- ✅ Creating replay systems or game recordings
- ✅ Queuing and scheduling actions (action queues for RTS games)
- ✅ Multiplayer game synchronization
Object Pooling: Performance Optimization 🏊
Object pooling is a creational pattern that recycles objects instead of creating and destroying them, which improves performance by reducing garbage collection pauses.
Basic Object Pool
class ObjectPool {
constructor(objectFactory, initialSize = 10) {
this.objectFactory = objectFactory;
this.pool = [];
this.activeObjects = new Set();
// Pre-populate pool
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.objectFactory());
}
}
get() {
// Get object from pool or create new one
const object = this.pool.length > 0
? this.pool.pop()
: this.objectFactory();
this.activeObjects.add(object);
return object;
}
release(object) {
if (this.activeObjects.has(object)) {
// Reset object state if needed
if (object.reset) {
object.reset();
}
this.activeObjects.delete(object);
this.pool.push(object);
}
}
// Optional: Update all active objects
update(deltaTime) {
for (const object of this.activeObjects) {
if (object.update) {
object.update(deltaTime);
// Optional: Auto-return objects that are done
if (object.isDone && object.isDone()) {
this.release(object);
}
}
}
}
}
// Usage for particle effects
class Particle {
constructor() {
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.life = 0;
this.maxLife = 0;
this.color = '#FFFFFF';
this.size = 2;
}
reset() {
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.life = 0;
this.maxLife = 0;
}
init(x, y, vx, vy, life, color, size) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.life = life;
this.maxLife = life;
this.color = color;
this.size = size;
}
update(deltaTime) {
this.x += this.vx * deltaTime;
this.y += this.vy * deltaTime;
this.life -= deltaTime;
}
isDone() {
return this.life <= 0;
}
draw(ctx) {
const alpha = this.life / this.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// Create a particle pool
const particlePool = new ObjectPool(() => new Particle(), 100);
// Create an explosion effect
function createExplosion(x, y, particleCount = 50) {
for (let i = 0; i < particleCount; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 50 + Math.random() * 50;
const life = 0.5 + Math.random() * 1.0;
const size = 1 + Math.random() * 3;
const particle = particlePool.get();
particle.init(
x,
y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
life,
'#FF4500',
size
);
}
}
// Update and draw particles
function update(deltaTime) {
particlePool.update(deltaTime);
}
function draw(ctx) {
for (const particle of particlePool.activeObjects) {
particle.draw(ctx);
}
}
Specialized Pools
For better performance, you can create specialized pools for specific game objects:
class BulletPool {
constructor(maxSize = 100) {
this.pool = [];
this.active = new Set();
// Create bullets up front
for (let i = 0; i < maxSize; i++) {
this.pool.push(new Bullet());
}
}
fire(x, y, angle, speed) {
if (this.pool.length === 0) {
console.warn("Bullet pool depleted!");
return null;
}
const bullet = this.pool.pop();
this.active.add(bullet);
// Initialize bullet
bullet.x = x;
bullet.y = y;
bullet.vx = Math.cos(angle) * speed;
bullet.vy = Math.sin(angle) * speed;
bullet.active = true;
return bullet;
}
recycle(bullet) {
if (this.active.has(bullet)) {
bullet.active = false;
this.active.delete(bullet);
this.pool.push(bullet);
}
}
update(deltaTime) {
const bulletsToRecycle = [];
for (const bullet of this.active) {
// Update position
bullet.x += bullet.vx * deltaTime;
bullet.y += bullet.vy * deltaTime;
// Check if bullet should be recycled
if (bullet.isOffScreen() || bullet.hasHit) {
bulletsToRecycle.push(bullet);
}
}
// Recycle bullets outside the loop to avoid modifying collection during iteration
for (const bullet of bulletsToRecycle) {
this.recycle(bullet);
}
}
}
When to Use Object Pooling:
- ✅ Short-lived objects created in large quantities (bullets, particles)
- ✅ Memory-intensive objects you want to reuse
- ✅ When garbage collection pauses affect gameplay smoothness
- ✅ Mobile games where memory management is critical
When to Avoid:
- ❌ Objects with significantly different initialization parameters
- ❌ When objects are long-lived and rarely created/destroyed
- ❌ When memory usage is more critical than CPU performance