Building maintainable and scalable games requires thoughtful architecture from the start. This comprehensive guide explores proven architectural patterns specifically adapted for Lua game development, helping you create games that can grow in complexity without becoming unmaintainable nightmares.
The Foundation: Why Architecture Matters
Game development is unique in software engineering. Games require real-time performance, complex state management, and the ability to iterate quickly on gameplay ideas. Poor architecture decisions early in development can compound into technical debt that strangles productivity and performance.
In Lua, where dynamic typing and flexible data structures provide both power and pitfalls, following established architectural patterns becomes even more crucial. The patterns we'll explore have been battle-tested in countless games and adapted specifically for Lua's strengths and characteristics.
Core Architectural Principles
- Separation of Concerns: Each module has a single responsibility
- Loose Coupling: Components don't depend directly on each other
- High Cohesion: Related functionality is grouped together
- Extensibility: New features can be added without massive refactoring
- Testability: Components can be tested in isolation
Pattern 1: Entity Component System (ECS)
The Entity Component System is perhaps the most popular architectural pattern in modern game development. It provides excellent flexibility for managing game objects with varying behaviors and properties.
ECS Fundamentals
ECS separates data (Components) from behavior (Systems) and uses lightweight identifiers (Entities) to associate them. This creates a highly flexible and performant architecture.
Basic ECS Implementation
-- Entity Component System implementation
local ECS = {}
ECS.__index = ECS
function ECS:new()
return setmetatable({
entities = {},
components = {},
systems = {},
nextEntityId = 1,
componentTypes = {}
}, ECS)
end
-- Entity management
function ECS:createEntity()
local id = self.nextEntityId
self.nextEntityId = self.nextEntityId + 1
self.entities[id] = {}
return id
end
function ECS:destroyEntity(entityId)
-- Remove all components
for componentType, _ in pairs(self.entities[entityId] or {}) do
self:removeComponent(entityId, componentType)
end
self.entities[entityId] = nil
end
-- Component management
function ECS:addComponent(entityId, componentType, data)
if not self.entities[entityId] then
error("Entity does not exist: " .. entityId)
end
if not self.components[componentType] then
self.components[componentType] = {}
end
self.components[componentType][entityId] = data
self.entities[entityId][componentType] = true
end
function ECS:removeComponent(entityId, componentType)
if self.components[componentType] then
self.components[componentType][entityId] = nil
end
if self.entities[entityId] then
self.entities[entityId][componentType] = nil
end
end
function ECS:getComponent(entityId, componentType)
if self.components[componentType] then
return self.components[componentType][entityId]
end
return nil
end
function ECS:hasComponent(entityId, componentType)
return self.entities[entityId] and
self.entities[entityId][componentType] == true
end
-- System management
function ECS:addSystem(system)
system.ecs = self
table.insert(self.systems, system)
if system.init then
system:init()
end
end
function ECS:update(dt)
for _, system in ipairs(self.systems) do
if system.update then
system:update(dt)
end
end
end
-- Query entities with specific components
function ECS:queryEntities(...)
local requiredComponents = {...}
local results = {}
for entityId, entityComponents in pairs(self.entities) do
local hasAll = true
for _, componentType in ipairs(requiredComponents) do
if not entityComponents[componentType] then
hasAll = false
break
end
end
if hasAll then
table.insert(results, entityId)
end
end
return results
end
-- Component definitions
local Components = {}
Components.Position = function(x, y)
return {x = x or 0, y = y or 0}
end
Components.Velocity = function(vx, vy)
return {vx = vx or 0, vy = vy or 0}
end
Components.Sprite = function(texture, width, height)
return {
texture = texture,
width = width or 32,
height = height or 32,
rotation = 0,
scale = 1
}
end
Components.Health = function(max)
return {
current = max or 100,
maximum = max or 100
}
end
Components.Input = function()
return {
left = false,
right = false,
up = false,
down = false,
action = false
}
end
-- System implementations
local MovementSystem = {}
MovementSystem.__index = MovementSystem
function MovementSystem:new()
return setmetatable({}, MovementSystem)
end
function MovementSystem:update(dt)
local entities = self.ecs:queryEntities("Position", "Velocity")
for _, entityId in ipairs(entities) do
local pos = self.ecs:getComponent(entityId, "Position")
local vel = self.ecs:getComponent(entityId, "Velocity")
pos.x = pos.x + vel.vx * dt
pos.y = pos.y + vel.vy * dt
end
end
local RenderSystem = {}
RenderSystem.__index = RenderSystem
function RenderSystem:new()
return setmetatable({}, RenderSystem)
end
function RenderSystem:update(dt)
local entities = self.ecs:queryEntities("Position", "Sprite")
for _, entityId in ipairs(entities) do
local pos = self.ecs:getComponent(entityId, "Position")
local sprite = self.ecs:getComponent(entityId, "Sprite")
-- Render sprite at position
-- love.graphics.draw(sprite.texture, pos.x, pos.y,
-- sprite.rotation, sprite.scale, sprite.scale)
end
end
-- Usage example
local function createGame()
local ecs = ECS:new()
-- Add systems
ecs:addSystem(MovementSystem:new())
ecs:addSystem(RenderSystem:new())
-- Create player entity
local player = ecs:createEntity()
ecs:addComponent(player, "Position", Components.Position(100, 100))
ecs:addComponent(player, "Velocity", Components.Velocity(0, 0))
ecs:addComponent(player, "Sprite", Components.Sprite(playerTexture, 32, 32))
ecs:addComponent(player, "Health", Components.Health(100))
ecs:addComponent(player, "Input", Components.Input())
-- Create enemy
local enemy = ecs:createEntity()
ecs:addComponent(enemy, "Position", Components.Position(200, 200))
ecs:addComponent(enemy, "Velocity", Components.Velocity(-50, 0))
ecs:addComponent(enemy, "Sprite", Components.Sprite(enemyTexture, 24, 24))
ecs:addComponent(enemy, "Health", Components.Health(50))
return ecs
end
Advanced ECS Features
A production-ready ECS needs additional features like component dependencies, system priorities, and efficient memory management:
-- Advanced ECS features
local AdvancedECS = setmetatable({}, {__index = ECS})
function AdvancedECS:new()
local ecs = ECS.new(self)
ecs.systemPriorities = {}
ecs.componentPools = {}
ecs.eventQueue = {}
return ecs
end
-- System priority management
function AdvancedECS:addSystem(system, priority)
ECS.addSystem(self, system)
self.systemPriorities[system] = priority or 0
-- Sort systems by priority
table.sort(self.systems, function(a, b)
return (self.systemPriorities[a] or 0) > (self.systemPriorities[b] or 0)
end)
end
-- Component pooling for performance
function AdvancedECS:addComponent(entityId, componentType, data)
if not self.componentPools[componentType] then
self.componentPools[componentType] = {}
end
-- Try to reuse pooled component
local component = table.remove(self.componentPools[componentType])
if component then
-- Reset component data
for k, v in pairs(data) do
component[k] = v
end
for k in pairs(component) do
if not data[k] then
component[k] = nil
end
end
else
component = data
end
ECS.addComponent(self, entityId, componentType, component)
end
function AdvancedECS:removeComponent(entityId, componentType)
local component = self:getComponent(entityId, componentType)
if component then
-- Return to pool
table.insert(self.componentPools[componentType], component)
end
ECS.removeComponent(self, entityId, componentType)
end
-- Event system for decoupled communication
function AdvancedECS:emit(eventType, data)
if not self.eventQueue[eventType] then
self.eventQueue[eventType] = {}
end
table.insert(self.eventQueue[eventType], data)
end
function AdvancedECS:processEvents()
for eventType, events in pairs(self.eventQueue) do
for _, system in ipairs(self.systems) do
if system.handleEvent then
for _, eventData in ipairs(events) do
system:handleEvent(eventType, eventData)
end
end
end
self.eventQueue[eventType] = {}
end
end
function AdvancedECS:update(dt)
-- Process events first
self:processEvents()
-- Update systems
ECS.update(self, dt)
end
-- Example: Collision System with events
local CollisionSystem = {}
CollisionSystem.__index = CollisionSystem
function CollisionSystem:new()
return setmetatable({}, CollisionSystem)
end
function CollisionSystem:update(dt)
local entities = self.ecs:queryEntities("Position", "Sprite")
-- Simple collision detection
for i = 1, #entities do
for j = i + 1, #entities do
local entity1 = entities[i]
local entity2 = entities[j]
local pos1 = self.ecs:getComponent(entity1, "Position")
local pos2 = self.ecs:getComponent(entity2, "Position")
local sprite1 = self.ecs:getComponent(entity1, "Sprite")
local sprite2 = self.ecs:getComponent(entity2, "Sprite")
if self:checkCollision(pos1, sprite1, pos2, sprite2) then
-- Emit collision event
self.ecs:emit("collision", {
entity1 = entity1,
entity2 = entity2
})
end
end
end
end
function CollisionSystem:checkCollision(pos1, sprite1, pos2, sprite2)
return pos1.x < pos2.x + sprite2.width and
pos1.x + sprite1.width > pos2.x and
pos1.y < pos2.y + sprite2.height and
pos1.y + sprite1.height > pos2.y
end
-- Health System that responds to collision events
local HealthSystem = {}
HealthSystem.__index = HealthSystem
function HealthSystem:new()
return setmetatable({}, HealthSystem)
end
function HealthSystem:handleEvent(eventType, eventData)
if eventType == "collision" then
-- Apply damage to entities with health
local entity1 = eventData.entity1
local entity2 = eventData.entity2
local health1 = self.ecs:getComponent(entity1, "Health")
local health2 = self.ecs:getComponent(entity2, "Health")
if health1 and health2 then
health1.current = health1.current - 10
health2.current = health2.current - 10
-- Check for death
if health1.current <= 0 then
self.ecs:emit("entity_death", {entity = entity1})
end
if health2.current <= 0 then
self.ecs:emit("entity_death", {entity = entity2})
end
end
elseif eventType == "entity_death" then
self.ecs:destroyEntity(eventData.entity)
end
end
Pattern 2: State Machine Architecture
State machines provide structure for managing complex game states and transitions. They're particularly useful for AI, game flow, and animation systems.
Hierarchical State Machine
Advanced State Machine Implementation
-- Hierarchical State Machine
local HSM = {}
HSM.__index = HSM
function HSM:new(initialState)
return setmetatable({
currentState = nil,
states = {},
globalState = nil,
previousState = nil,
stateStack = {},
owner = nil
}, HSM)
end
-- State class
local State = {}
State.__index = State
function State:new(name)
return setmetatable({
name = name,
subStates = {},
currentSubState = nil,
parent = nil
}, State)
end
function State:enter(owner) end
function State:execute(owner, dt) end
function State:exit(owner) end
function State:handleMessage(owner, message) return false end
-- Add sub-state functionality
function State:addSubState(subState)
subState.parent = self
self.subStates[subState.name] = subState
end
function State:changeSubState(newSubStateName)
if self.currentSubState then
self.currentSubState:exit(self.owner)
end
self.currentSubState = self.subStates[newSubStateName]
if self.currentSubState then
self.currentSubState:enter(self.owner)
end
end
-- HSM methods
function HSM:addState(state)
self.states[state.name] = state
end
function HSM:changeState(newStateName)
if self.currentState then
self.currentState:exit(self.owner)
self.previousState = self.currentState
end
self.currentState = self.states[newStateName]
if self.currentState then
self.currentState.owner = self.owner
self.currentState:enter(self.owner)
end
end
function HSM:pushState(newStateName)
if self.currentState then
table.insert(self.stateStack, self.currentState)
end
self:changeState(newStateName)
end
function HSM:popState()
if #self.stateStack > 0 then
local previousState = table.remove(self.stateStack)
self:changeState(previousState.name)
end
end
function HSM:update(dt)
if self.globalState then
self.globalState:execute(self.owner, dt)
end
if self.currentState then
self.currentState:execute(self.owner, dt)
-- Update sub-states
if self.currentState.currentSubState then
self.currentState.currentSubState:execute(self.owner, dt)
end
end
end
function HSM:handleMessage(message)
-- Try current sub-state first
if self.currentState and self.currentState.currentSubState then
if self.currentState.currentSubState:handleMessage(self.owner, message) then
return true
end
end
-- Try current state
if self.currentState and self.currentState:handleMessage(self.owner, message) then
return true
end
-- Try global state
if self.globalState and self.globalState:handleMessage(self.owner, message) then
return true
end
return false
end
-- Example: AI Character States
local IdleState = setmetatable({}, {__index = State})
function IdleState:new()
local state = State.new(self, "Idle")
return setmetatable(state, IdleState)
end
function IdleState:enter(owner)
print(owner.name .. " is now idle")
owner.velocity = {x = 0, y = 0}
owner.idleTimer = 0
end
function IdleState:execute(owner, dt)
owner.idleTimer = owner.idleTimer + dt
-- Look for nearby enemies
local nearbyEnemies = findNearbyEnemies(owner, 100)
if #nearbyEnemies > 0 then
owner.target = nearbyEnemies[1]
owner.stateMachine:changeState("Chase")
return
end
-- Random movement after idle time
if owner.idleTimer > 3 then
owner.stateMachine:changeState("Patrol")
end
end
local ChaseState = setmetatable({}, {__index = State})
function ChaseState:new()
local state = State.new(self, "Chase")
return setmetatable(state, ChaseState)
end
function ChaseState:enter(owner)
print(owner.name .. " is chasing " .. owner.target.name)
end
function ChaseState:execute(owner, dt)
if not owner.target or owner.target.health <= 0 then
owner.target = nil
owner.stateMachine:changeState("Idle")
return
end
-- Move towards target
local dx = owner.target.x - owner.x
local dy = owner.target.y - owner.y
local distance = math.sqrt(dx * dx + dy * dy)
if distance < 30 then
owner.stateMachine:changeState("Attack")
return
end
if distance > 200 then
owner.target = nil
owner.stateMachine:changeState("Idle")
return
end
-- Normalize direction and set velocity
owner.velocity.x = (dx / distance) * owner.speed
owner.velocity.y = (dy / distance) * owner.speed
end
local AttackState = setmetatable({}, {__index = State})
function AttackState:new()
local state = State.new(self, "Attack")
-- Add sub-states for complex attack behavior
local windupState = State:new("Windup")
windupState.enter = function(self, owner)
owner.attackTimer = 0
owner.velocity = {x = 0, y = 0}
end
windupState.execute = function(self, owner, dt)
owner.attackTimer = owner.attackTimer + dt
if owner.attackTimer >= 0.5 then
self.parent:changeSubState("Strike")
end
end
local strikeState = State:new("Strike")
strikeState.enter = function(self, owner)
-- Deal damage to target
if owner.target then
owner.target.health = owner.target.health - owner.damage
end
owner.attackTimer = 0
end
strikeState.execute = function(self, owner, dt)
owner.attackTimer = owner.attackTimer + dt
if owner.attackTimer >= 0.3 then
self.parent:changeSubState("Recovery")
end
end
local recoveryState = State:new("Recovery")
recoveryState.execute = function(self, owner, dt)
owner.attackTimer = owner.attackTimer + dt
if owner.attackTimer >= 0.2 then
owner.stateMachine:changeState("Chase")
end
end
state:addSubState(windupState)
state:addSubState(strikeState)
state:addSubState(recoveryState)
return setmetatable(state, AttackState)
end
function AttackState:enter(owner)
print(owner.name .. " is attacking " .. owner.target.name)
self:changeSubState("Windup")
end
-- Create AI character with state machine
local function createAICharacter(name, x, y)
local character = {
name = name,
x = x, y = y,
health = 100,
speed = 50,
damage = 25,
velocity = {x = 0, y = 0},
target = nil,
stateMachine = HSM:new()
}
character.stateMachine.owner = character
-- Add states
character.stateMachine:addState(IdleState:new())
character.stateMachine:addState(ChaseState:new())
character.stateMachine:addState(AttackState:new())
character.stateMachine:changeState("Idle")
return character
end
Pattern 3: Observer Pattern for Events
The Observer pattern enables loose coupling between game systems by allowing objects to subscribe to and receive notifications about events without direct dependencies.
-- Event Manager with Observer Pattern
local EventManager = {}
EventManager.__index = EventManager
function EventManager:new()
return setmetatable({
listeners = {},
eventQueue = {},
processing = false
}, EventManager)
end
function EventManager:subscribe(eventType, listener, callback)
if not self.listeners[eventType] then
self.listeners[eventType] = {}
end
table.insert(self.listeners[eventType], {
listener = listener,
callback = callback
})
end
function EventManager:unsubscribe(eventType, listener)
if not self.listeners[eventType] then return end
for i = #self.listeners[eventType], 1, -1 do
if self.listeners[eventType][i].listener == listener then
table.remove(self.listeners[eventType], i)
end
end
end
function EventManager:emit(eventType, data)
local event = {
type = eventType,
data = data,
timestamp = os.clock()
}
if self.processing then
-- Queue events during processing to avoid infinite loops
table.insert(self.eventQueue, event)
else
self:processEvent(event)
end
end
function EventManager:processEvent(event)
local listeners = self.listeners[event.type]
if not listeners then return end
for _, listener in ipairs(listeners) do
if listener.callback then
listener.callback(listener.listener, event.data)
elseif listener.listener[event.type] then
listener.listener[event.type](listener.listener, event.data)
end
end
end
function EventManager:processQueue()
self.processing = true
while #self.eventQueue > 0 do
local event = table.remove(self.eventQueue, 1)
self:processEvent(event)
end
self.processing = false
end
-- Global event manager
local Events = EventManager:new()
-- Example: Game Systems Using Events
local ScoreSystem = {}
function ScoreSystem:new()
local system = {score = 0, multiplier = 1}
-- Subscribe to events
Events:subscribe("enemy_killed", system, function(self, data)
self.score = self.score + (data.points * self.multiplier)
Events:emit("score_changed", {score = self.score})
end)
Events:subscribe("powerup_collected", system, function(self, data)
if data.type == "multiplier" then
self.multiplier = self.multiplier * 2
Events:emit("multiplier_changed", {multiplier = self.multiplier})
end
end)
return system
end
local UISystem = {}
function UISystem:new()
local system = {scoreText = "", multiplierText = ""}
Events:subscribe("score_changed", system, function(self, data)
self.scoreText = "Score: " .. data.score
end)
Events:subscribe("multiplier_changed", system, function(self, data)
self.multiplierText = "x" .. data.multiplier
end)
Events:subscribe("player_health_changed", system, function(self, data)
-- Update health bar
self.healthPercent = data.current / data.maximum
end)
return system
end
local SoundSystem = {}
function SoundSystem:new()
local system = {sounds = {}}
Events:subscribe("enemy_killed", system, function(self, data)
self:playSound("enemy_death")
end)
Events:subscribe("player_hit", system, function(self, data)
self:playSound("player_hurt")
end)
Events:subscribe("powerup_collected", system, function(self, data)
self:playSound("powerup_" .. data.type)
end)
function system:playSound(soundName)
-- Play sound implementation
print("Playing sound:", soundName)
end
return system
end
-- Achievement System using events
local AchievementSystem = {}
function AchievementSystem:new()
local system = {
achievements = {},
stats = {
enemiesKilled = 0,
powerupsCollected = 0,
totalScore = 0
}
}
-- Define achievements
system.achievements.firstKill = {
name = "First Blood",
description = "Kill your first enemy",
unlocked = false,
condition = function(stats) return stats.enemiesKilled >= 1 end
}
system.achievements.collector = {
name = "Collector",
description = "Collect 10 powerups",
unlocked = false,
condition = function(stats) return stats.powerupsCollected >= 10 end
}
system.achievements.highScore = {
name = "High Scorer",
description = "Reach 10,000 points",
unlocked = false,
condition = function(stats) return stats.totalScore >= 10000 end
}
-- Subscribe to events
Events:subscribe("enemy_killed", system, function(self, data)
self.stats.enemiesKilled = self.stats.enemiesKilled + 1
self:checkAchievements()
end)
Events:subscribe("powerup_collected", system, function(self, data)
self.stats.powerupsCollected = self.stats.powerupsCollected + 1
self:checkAchievements()
end)
Events:subscribe("score_changed", system, function(self, data)
self.stats.totalScore = data.score
self:checkAchievements()
end)
function system:checkAchievements()
for id, achievement in pairs(self.achievements) do
if not achievement.unlocked and achievement.condition(self.stats) then
achievement.unlocked = true
Events:emit("achievement_unlocked", {
id = id,
name = achievement.name,
description = achievement.description
})
end
end
end
return system
end
Pattern 4: Command Pattern for Input and Undo
The Command pattern encapsulates actions as objects, enabling features like input remapping, action queuing, and undo/redo functionality.
Command Pattern Implementation
-- Command base class
local Command = {}
Command.__index = Command
function Command:new()
return setmetatable({}, self)
end
function Command:execute()
error("Command:execute() must be implemented")
end
function Command:undo()
-- Optional: implement for undoable commands
end
function Command:canExecute()
return true
end
-- Concrete command implementations
local MoveCommand = setmetatable({}, {__index = Command})
function MoveCommand:new(actor, direction, distance)
local cmd = Command.new(self)
cmd.actor = actor
cmd.direction = direction
cmd.distance = distance or 1
cmd.previousPosition = nil
return setmetatable(cmd, MoveCommand)
end
function MoveCommand:execute()
if not self:canExecute() then return false end
-- Store previous position for undo
self.previousPosition = {x = self.actor.x, y = self.actor.y}
-- Execute movement
if self.direction == "up" then
self.actor.y = self.actor.y - self.distance
elseif self.direction == "down" then
self.actor.y = self.actor.y + self.distance
elseif self.direction == "left" then
self.actor.x = self.actor.x - self.distance
elseif self.direction == "right" then
self.actor.x = self.actor.x + self.distance
end
return true
end
function MoveCommand:undo()
if self.previousPosition then
self.actor.x = self.previousPosition.x
self.actor.y = self.previousPosition.y
end
end
function MoveCommand:canExecute()
-- Check bounds, obstacles, etc.
return self.actor and self.actor.canMove
end
local AttackCommand = setmetatable({}, {__index = Command})
function AttackCommand:new(attacker, target)
local cmd = Command.new(self)
cmd.attacker = attacker
cmd.target = target
cmd.damageDealt = 0
return setmetatable(cmd, AttackCommand)
end
function AttackCommand:execute()
if not self:canExecute() then return false end
self.damageDealt = self.attacker.attack
self.target.health = self.target.health - self.damageDealt
Events:emit("attack_executed", {
attacker = self.attacker,
target = self.target,
damage = self.damageDealt
})
return true
end
function AttackCommand:undo()
self.target.health = self.target.health + self.damageDealt
end
function AttackCommand:canExecute()
return self.attacker and self.target and
self.target.health > 0 and
getDistance(self.attacker, self.target) <= self.attacker.range
end
-- Command Manager for queuing and undo/redo
local CommandManager = {}
CommandManager.__index = CommandManager
function CommandManager:new()
return setmetatable({
commandQueue = {},
history = {},
historyIndex = 0,
maxHistorySize = 100
}, CommandManager)
end
function CommandManager:queueCommand(command)
table.insert(self.commandQueue, command)
end
function CommandManager:executeQueue()
while #self.commandQueue > 0 do
local command = table.remove(self.commandQueue, 1)
self:executeCommand(command)
end
end
function CommandManager:executeCommand(command)
if command:execute() then
-- Add to history for undo
self:addToHistory(command)
end
end
function CommandManager:addToHistory(command)
-- Remove any commands after current index (for redo functionality)
for i = self.historyIndex + 1, #self.history do
self.history[i] = nil
end
table.insert(self.history, command)
self.historyIndex = #self.history
-- Limit history size
if #self.history > self.maxHistorySize then
table.remove(self.history, 1)
self.historyIndex = self.historyIndex - 1
end
end
function CommandManager:undo()
if self.historyIndex > 0 then
local command = self.history[self.historyIndex]
if command.undo then
command:undo()
end
self.historyIndex = self.historyIndex - 1
return true
end
return false
end
function CommandManager:redo()
if self.historyIndex < #self.history then
self.historyIndex = self.historyIndex + 1
local command = self.history[self.historyIndex]
command:execute()
return true
end
return false
end
-- Input Manager using Command Pattern
local InputManager = {}
InputManager.__index = InputManager
function InputManager:new(commandManager)
return setmetatable({
commandManager = commandManager,
keyBindings = {},
inputBuffer = {},
bufferSize = 10
}, InputManager)
end
function InputManager:bindKey(key, commandFactory)
self.keyBindings[key] = commandFactory
end
function InputManager:handleInput(key, pressed)
if pressed and self.keyBindings[key] then
local command = self.keyBindings[key]()
if command then
self.commandManager:queueCommand(command)
-- Add to input buffer for combo detection
table.insert(self.inputBuffer, {key = key, time = os.clock()})
if #self.inputBuffer > self.bufferSize then
table.remove(self.inputBuffer, 1)
end
end
end
end
function InputManager:checkCombo(sequence)
if #self.inputBuffer < #sequence then return false end
local bufferStart = #self.inputBuffer - #sequence + 1
for i, expectedKey in ipairs(sequence) do
if self.inputBuffer[bufferStart + i - 1].key ~= expectedKey then
return false
end
end
-- Check timing (all inputs within 2 seconds)
local firstTime = self.inputBuffer[bufferStart].time
local lastTime = self.inputBuffer[#self.inputBuffer].time
return (lastTime - firstTime) <= 2.0
end
-- Usage Example
local function setupGameInput()
local player = {x = 100, y = 100, canMove = true, health = 100, attack = 25, range = 50}
local commandManager = CommandManager:new()
local inputManager = InputManager:new(commandManager)
-- Bind keys to command factories
inputManager:bindKey("w", function()
return MoveCommand:new(player, "up", 32)
end)
inputManager:bindKey("s", function()
return MoveCommand:new(player, "down", 32)
end)
inputManager:bindKey("a", function()
return MoveCommand:new(player, "left", 32)
end)
inputManager:bindKey("d", function()
return MoveCommand:new(player, "right", 32)
end)
inputManager:bindKey("space", function()
local nearestEnemy = findNearestEnemy(player)
if nearestEnemy then
return AttackCommand:new(player, nearestEnemy)
end
return nil
end)
-- Combo attacks
inputManager:bindKey("combo_fireball", function()
if inputManager:checkCombo({"s", "d", "s", "d", "space"}) then
return FireballCommand:new(player)
end
return nil
end)
return inputManager, commandManager
end
Pattern 5: Factory Pattern for Object Creation
The Factory pattern centralizes object creation, making it easier to manage different types of game objects and their initialization logic.
-- Abstract Factory for Game Objects
local GameObjectFactory = {}
GameObjectFactory.__index = GameObjectFactory
function GameObjectFactory:new()
return setmetatable({
templates = {},
constructors = {}
}, GameObjectFactory)
end
function GameObjectFactory:registerTemplate(name, template)
self.templates[name] = template
end
function GameObjectFactory:registerConstructor(type, constructor)
self.constructors[type] = constructor
end
function GameObjectFactory:create(templateName, overrides)
local template = self.templates[templateName]
if not template then
error("Unknown template: " .. templateName)
end
local constructor = self.constructors[template.type]
if not constructor then
error("No constructor for type: " .. template.type)
end
-- Merge template with overrides
local config = {}
for k, v in pairs(template) do
config[k] = v
end
if overrides then
for k, v in pairs(overrides) do
config[k] = v
end
end
return constructor(config)
end
-- Base game object
local GameObject = {}
GameObject.__index = GameObject
function GameObject:new(config)
local obj = setmetatable({
id = generateUniqueId(),
x = config.x or 0,
y = config.y or 0,
width = config.width or 32,
height = config.height or 32,
active = true,
components = {}
}, self)
return obj
end
function GameObject:addComponent(component)
self.components[component.type] = component
component.owner = self
end
function GameObject:getComponent(type)
return self.components[type]
end
function GameObject:update(dt)
for _, component in pairs(self.components) do
if component.update then
component:update(dt)
end
end
end
-- Specific game object types
local Enemy = setmetatable({}, {__index = GameObject})
function Enemy:new(config)
local enemy = GameObject.new(self, config)
enemy.health = config.health or 100
enemy.maxHealth = enemy.health
enemy.damage = config.damage or 10
enemy.speed = config.speed or 50
enemy.aiType = config.aiType or "basic"
enemy.dropTable = config.dropTable or {}
-- Add AI component based on type
if enemy.aiType == "basic" then
enemy:addComponent(BasicAI:new())
elseif enemy.aiType == "aggressive" then
enemy:addComponent(AggressiveAI:new())
elseif enemy.aiType == "defensive" then
enemy:addComponent(DefensiveAI:new())
end
-- Add other components
enemy:addComponent(HealthComponent:new(enemy.health))
enemy:addComponent(MovementComponent:new(enemy.speed))
return enemy
end
local Projectile = setmetatable({}, {__index = GameObject})
function Projectile:new(config)
local projectile = GameObject.new(self, config)
projectile.damage = config.damage or 25
projectile.speed = config.speed or 200
projectile.lifetime = config.lifetime or 5.0
projectile.piercing = config.piercing or false
projectile.owner = config.owner
-- Set velocity based on angle
local angle = config.angle or 0
projectile.vx = math.cos(angle) * projectile.speed
projectile.vy = math.sin(angle) * projectile.speed
projectile:addComponent(ProjectileComponent:new(projectile.lifetime))
projectile:addComponent(DamageComponent:new(projectile.damage, projectile.piercing))
return projectile
end
local Pickup = setmetatable({}, {__index = GameObject})
function Pickup:new(config)
local pickup = GameObject.new(self, config)
pickup.pickupType = config.pickupType or "coin"
pickup.value = config.value or 1
pickup.magnetRadius = config.magnetRadius or 0
pickup.lifetime = config.lifetime or 30
pickup:addComponent(PickupComponent:new(pickup.pickupType, pickup.value))
if pickup.magnetRadius > 0 then
pickup:addComponent(MagnetComponent:new(pickup.magnetRadius))
end
return pickup
end
-- Factory setup with templates
local function setupGameObjectFactory()
local factory = GameObjectFactory:new()
-- Register constructors
factory:registerConstructor("enemy", function(config) return Enemy:new(config) end)
factory:registerConstructor("projectile", function(config) return Projectile:new(config) end)
factory:registerConstructor("pickup", function(config) return Pickup:new(config) end)
-- Register enemy templates
factory:registerTemplate("goblin", {
type = "enemy",
health = 50,
damage = 8,
speed = 40,
aiType = "basic",
width = 24,
height = 24,
dropTable = {
{item = "coin", chance = 0.8, quantity = {1, 3}},
{item = "health_potion", chance = 0.1, quantity = 1}
}
})
factory:registerTemplate("orc", {
type = "enemy",
health = 100,
damage = 15,
speed = 30,
aiType = "aggressive",
width = 32,
height = 32,
dropTable = {
{item = "coin", chance = 0.9, quantity = {3, 8}},
{item = "weapon_upgrade", chance = 0.05, quantity = 1}
}
})
factory:registerTemplate("skeleton_archer", {
type = "enemy",
health = 75,
damage = 12,
speed = 25,
aiType = "ranged",
range = 150,
attackRate = 2.0,
width = 28,
height = 28
})
-- Register projectile templates
factory:registerTemplate("arrow", {
type = "projectile",
damage = 20,
speed = 300,
lifetime = 3.0,
piercing = false,
width = 8,
height = 8
})
factory:registerTemplate("fireball", {
type = "projectile",
damage = 40,
speed = 150,
lifetime = 4.0,
piercing = true,
explosionRadius = 50,
width = 16,
height = 16
})
-- Register pickup templates
factory:registerTemplate("health_potion", {
type = "pickup",
pickupType = "health",
value = 25,
magnetRadius = 30,
width = 16,
height = 16
})
factory:registerTemplate("coin", {
type = "pickup",
pickupType = "currency",
value = 1,
magnetRadius = 40,
lifetime = 60,
width = 12,
height = 12
})
return factory
end
-- Usage examples
local function spawnEnemyWave(factory, waveConfig)
local enemies = {}
for _, enemyConfig in ipairs(waveConfig.enemies) do
for i = 1, enemyConfig.count do
local enemy = factory:create(enemyConfig.type, {
x = enemyConfig.spawnArea.x + math.random() * enemyConfig.spawnArea.width,
y = enemyConfig.spawnArea.y + math.random() * enemyConfig.spawnArea.height
})
table.insert(enemies, enemy)
end
end
return enemies
end
-- Example wave configuration
local wave1 = {
enemies = {
{type = "goblin", count = 5, spawnArea = {x = 100, y = 100, width = 200, height = 100}},
{type = "orc", count = 2, spawnArea = {x = 150, y = 50, width = 100, height = 200}}
}
}
local factory = setupGameObjectFactory()
local enemies = spawnEnemyWave(factory, wave1)
Pattern 6: Object Pool Pattern
Object pooling is crucial for performance in games, especially for frequently created and destroyed objects like bullets, particles, and enemies.
Generic Object Pool
-- Generic Object Pool
local ObjectPool = {}
ObjectPool.__index = ObjectPool
function ObjectPool:new(createFunc, resetFunc, initialSize)
local pool = setmetatable({
createFunc = createFunc,
resetFunc = resetFunc,
available = {},
active = {},
totalCreated = 0,
peakActive = 0
}, ObjectPool)
-- Pre-populate pool
initialSize = initialSize or 10
for i = 1, initialSize do
local obj = createFunc()
obj._pooled = true
table.insert(pool.available, obj)
pool.totalCreated = pool.totalCreated + 1
end
return pool
end
function ObjectPool:acquire()
local obj
if #self.available > 0 then
obj = table.remove(self.available)
else
obj = self.createFunc()
obj._pooled = true
self.totalCreated = self.totalCreated + 1
end
self.active[obj] = true
local activeCount = 0
for _ in pairs(self.active) do activeCount = activeCount + 1 end
self.peakActive = math.max(self.peakActive, activeCount)
return obj
end
function ObjectPool:release(obj)
if not obj._pooled or not self.active[obj] then
return -- Object not from this pool or already released
end
self.active[obj] = nil
if self.resetFunc then
self.resetFunc(obj)
end
table.insert(self.available, obj)
end
function ObjectPool:getStats()
local activeCount = 0
for _ in pairs(self.active) do activeCount = activeCount + 1 end
return {
totalCreated = self.totalCreated,
available = #self.available,
active = activeCount,
peakActive = self.peakActive
}
end
-- Pool Manager for multiple object types
local PoolManager = {}
PoolManager.__index = PoolManager
function PoolManager:new()
return setmetatable({
pools = {}
}, PoolManager)
end
function PoolManager:createPool(name, createFunc, resetFunc, initialSize)
self.pools[name] = ObjectPool:new(createFunc, resetFunc, initialSize)
end
function PoolManager:acquire(poolName)
local pool = self.pools[poolName]
if not pool then
error("Pool not found: " .. poolName)
end
return pool:acquire()
end
function PoolManager:release(poolName, obj)
local pool = self.pools[poolName]
if pool then
pool:release(obj)
end
end
function PoolManager:printStats()
print("=== Pool Manager Stats ===")
for name, pool in pairs(self.pools) do
local stats = pool:getStats()
print(string.format("%s: %d total, %d active, %d available, peak %d",
name, stats.totalCreated, stats.active, stats.available, stats.peakActive))
end
end
-- Example: Bullet Pool
local Bullet = {}
Bullet.__index = Bullet
function Bullet:new()
return setmetatable({
x = 0, y = 0,
vx = 0, vy = 0,
damage = 0,
lifetime = 0,
maxLifetime = 0,
active = false,
owner = nil
}, Bullet)
end
function Bullet:init(x, y, angle, speed, damage, lifetime, owner)
self.x = x
self.y = y
self.vx = math.cos(angle) * speed
self.vy = math.sin(angle) * speed
self.damage = damage
self.lifetime = 0
self.maxLifetime = lifetime
self.active = true
self.owner = owner
end
function Bullet:update(dt)
if not self.active then return end
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
self.lifetime = self.lifetime + dt
-- Check if bullet should expire
if self.lifetime >= self.maxLifetime then
self:deactivate()
end
-- Check bounds (assuming screen bounds)
if self.x < -50 or self.x > screenWidth + 50 or
self.y < -50 or self.y > screenHeight + 50 then
self:deactivate()
end
end
function Bullet:deactivate()
self.active = false
-- Release back to pool
if self._pooled then
poolManager:release("bullets", self)
end
end
function Bullet:reset()
self.x = 0
self.y = 0
self.vx = 0
self.vy = 0
self.damage = 0
self.lifetime = 0
self.maxLifetime = 0
self.active = false
self.owner = nil
end
-- Particle System with Pooling
local Particle = {}
Particle.__index = Particle
function Particle:new()
return setmetatable({
x = 0, y = 0,
vx = 0, vy = 0,
ax = 0, ay = 0,
color = {255, 255, 255, 255},
size = 1,
lifetime = 0,
maxLifetime = 1,
active = false
}, Particle)
end
function Particle:init(config)
self.x = config.x or 0
self.y = config.y or 0
self.vx = config.vx or 0
self.vy = config.vy or 0
self.ax = config.ax or 0
self.ay = config.ay or 0
self.color = config.color or {255, 255, 255, 255}
self.size = config.size or 1
self.lifetime = 0
self.maxLifetime = config.maxLifetime or 1
self.active = true
end
function Particle:update(dt)
if not self.active then return end
self.vx = self.vx + self.ax * dt
self.vy = self.vy + self.ay * dt
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
self.lifetime = self.lifetime + dt
-- Fade out over time
local fadeProgress = self.lifetime / self.maxLifetime
self.color[4] = math.max(0, 255 * (1 - fadeProgress))
if self.lifetime >= self.maxLifetime then
self:deactivate()
end
end
function Particle:deactivate()
self.active = false
if self._pooled then
poolManager:release("particles", self)
end
end
function Particle:reset()
self.x = 0
self.y = 0
self.vx = 0
self.vy = 0
self.ax = 0
self.ay = 0
self.color = {255, 255, 255, 255}
self.size = 1
self.lifetime = 0
self.maxLifetime = 1
self.active = false
end
-- Setup pools
local function setupPools()
local poolManager = PoolManager:new()
-- Bullet pool
poolManager:createPool("bullets",
function() return Bullet:new() end,
function(bullet) bullet:reset() end,
50 -- Initial size
)
-- Particle pool
poolManager:createPool("particles",
function() return Particle:new() end,
function(particle) particle:reset() end,
200 -- Initial size
)
-- Enemy pool (for spawning systems)
poolManager:createPool("enemies",
function() return Enemy:new({}) end,
function(enemy) enemy:reset() end,
20
)
return poolManager
end
-- Usage in game systems
local WeaponSystem = {}
function WeaponSystem:new(poolManager)
return setmetatable({
poolManager = poolManager
}, WeaponSystem)
end
function WeaponSystem:fire(weapon, x, y, angle)
local bullet = self.poolManager:acquire("bullets")
bullet:init(x, y, angle, weapon.bulletSpeed, weapon.damage, weapon.range, weapon.owner)
-- Spawn muzzle flash particles
for i = 1, 5 do
local particle = self.poolManager:acquire("particles")
particle:init({
x = x + math.random(-5, 5),
y = y + math.random(-5, 5),
vx = math.random(-50, 50),
vy = math.random(-50, 50),
color = {255, 200, 100, 255},
size = math.random(2, 4),
maxLifetime = 0.2
})
end
return bullet
end
Bringing It All Together: Complete Game Architecture
Let's see how all these patterns work together in a complete game architecture. This example demonstrates how the patterns complement each other to create a maintainable and scalable codebase.
-- Complete Game Architecture Example
local Game = {}
Game.__index = Game
function Game:new()
return setmetatable({
ecs = AdvancedECS:new(),
eventManager = EventManager:new(),
poolManager = PoolManager:new(),
factory = GameObjectFactory:new(),
commandManager = CommandManager:new(),
inputManager = nil,
-- Game state
gameState = "menu", -- menu, playing, paused, gameover
score = 0,
level = 1,
-- Systems
systems = {},
-- Performance tracking
frameTime = 0,
updateTime = 0,
renderTime = 0
}, Game)
end
function Game:initialize()
-- Setup pools
self:setupPools()
-- Setup factory
self:setupFactory()
-- Setup input
self.inputManager = InputManager:new(self.commandManager)
self:setupInput()
-- Setup systems
self:setupSystems()
-- Setup event listeners
self:setupEventListeners()
print("Game initialized successfully")
end
function Game:setupPools()
self.poolManager:createPool("bullets",
function() return Bullet:new() end,
function(b) b:reset() end, 100)
self.poolManager:createPool("particles",
function() return Particle:new() end,
function(p) p:reset() end, 500)
self.poolManager:createPool("enemies",
function() return Enemy:new({}) end,
function(e) e:reset() end, 50)
end
function Game:setupFactory()
-- Register all game object templates
setupGameObjectFactory(self.factory)
end
function Game:setupSystems()
-- Add ECS systems with priorities
self.ecs:addSystem(InputSystem:new(self.inputManager), 100)
self.ecs:addSystem(MovementSystem:new(), 90)
self.ecs:addSystem(CollisionSystem:new(), 80)
self.ecs:addSystem(WeaponSystem:new(self.poolManager), 70)
self.ecs:addSystem(AISystem:new(), 60)
self.ecs:addSystem(HealthSystem:new(), 50)
self.ecs:addSystem(RenderSystem:new(), 10)
-- Add game-specific systems
table.insert(self.systems, ScoreSystem:new())
table.insert(self.systems, UISystem:new())
table.insert(self.systems, SoundSystem:new())
table.insert(self.systems, AchievementSystem:new())
end
function Game:setupInput()
-- Basic movement
self.inputManager:bindKey("w", function()
return MoveCommand:new(self:getPlayer(), "up", 100)
end)
-- More input bindings...
end
function Game:setupEventListeners()
self.eventManager:subscribe("enemy_killed", self, function(self, data)
self.score = self.score + data.points
-- Chance to spawn pickup
if math.random() < 0.3 then
local pickup = self.factory:create("coin", {
x = data.enemy.x,
y = data.enemy.y
})
self:addGameObject(pickup)
end
end)
self.eventManager:subscribe("player_death", self, function(self, data)
self:changeState("gameover")
end)
end
function Game:update(dt)
local startTime = os.clock()
-- Update command manager
self.commandManager:executeQueue()
-- Update ECS
self.ecs:update(dt)
-- Update other systems
for _, system in ipairs(self.systems) do
if system.update then
system:update(dt)
end
end
-- Process events
self.eventManager:processQueue()
-- Update pools (cleanup inactive objects)
self:updatePools(dt)
self.updateTime = os.clock() - startTime
end
function Game:render()
local startTime = os.clock()
-- ECS rendering is handled by RenderSystem
-- Additional UI rendering here
self:renderUI()
self.renderTime = os.clock() - startTime
end
function Game:updatePools(dt)
-- Update all active bullets
for bullet in pairs(self.poolManager.pools.bullets.active) do
bullet:update(dt)
end
-- Update all active particles
for particle in pairs(self.poolManager.pools.particles.active) do
particle:update(dt)
end
end
function Game:addGameObject(obj)
if obj.type == "player" then
local entity = self.ecs:createEntity()
self.ecs:addComponent(entity, "Position", {x = obj.x, y = obj.y})
self.ecs:addComponent(entity, "Sprite", obj.sprite)
self.ecs:addComponent(entity, "Health", {current = obj.health, max = obj.health})
self.ecs:addComponent(entity, "Input", {})
self.ecs:addComponent(entity, "Player", {})
obj.entityId = entity
elseif obj.type == "enemy" then
local entity = self.ecs:createEntity()
self.ecs:addComponent(entity, "Position", {x = obj.x, y = obj.y})
self.ecs:addComponent(entity, "Sprite", obj.sprite)
self.ecs:addComponent(entity, "Health", {current = obj.health, max = obj.health})
self.ecs:addComponent(entity, "AI", {type = obj.aiType})
self.ecs:addComponent(entity, "Enemy", {damage = obj.damage})
obj.entityId = entity
end
return obj
end
function Game:changeState(newState)
local oldState = self.gameState
self.gameState = newState
self.eventManager:emit("state_changed", {
from = oldState,
to = newState
})
-- State-specific logic
if newState == "playing" then
self:startLevel(self.level)
elseif newState == "gameover" then
self:saveHighScore()
end
end
function Game:getPlayer()
local players = self.ecs:queryEntities("Player")
if #players > 0 then
return players[1]
end
return nil
end
-- Main game loop
function Game:run()
local lastTime = os.clock()
while self.gameState ~= "quit" do
local currentTime = os.clock()
local dt = currentTime - lastTime
lastTime = currentTime
self.frameTime = dt
-- Handle input
handleSystemInput(self.inputManager)
-- Update game
if self.gameState == "playing" then
self:update(dt)
end
-- Render
self:render()
-- Cap framerate
local targetFrameTime = 1/60
local actualFrameTime = os.clock() - currentTime
if actualFrameTime < targetFrameTime then
sleep(targetFrameTime - actualFrameTime)
end
end
end
-- Usage
local game = Game:new()
game:initialize()
game:run()
Performance Considerations
When implementing these architectural patterns, keep performance in mind. Here are key optimization strategies:
Performance Best Practices
- Cache frequently accessed components: Store references instead of repeated queries
- Use spatial partitioning: Don't check collisions between distant objects
- Limit event processing: Queue events and process in batches
- Profile regularly: Measure actual performance, not assumptions
- Pool everything: Reuse objects to reduce garbage collection pressure
- Batch similar operations: Group rendering calls, audio updates, etc.
Conclusion
Good game architecture is like a well-planned city - it provides structure that enables growth while maintaining efficiency and livability. The patterns we've explored aren't just theoretical concepts; they're battle-tested solutions that have powered countless successful games.
Remember that architecture is not about following patterns blindly, but about choosing the right tools for your specific problems. Start simple, measure performance, and refactor when complexity demands it. A small indie game might only need basic ECS and event systems, while a large MMO might require all the patterns discussed and more.
The key to success is understanding these patterns deeply enough to adapt them to your needs. Lua's flexibility makes it an excellent language for implementing these patterns, but that same flexibility requires discipline to maintain clean, organized code.
As you build your games, remember that great architecture is invisible to players but essential for developers. Invest the time to build solid foundations, and your future self will thank you when you're adding that amazing new feature instead of fighting with tangled code.
Now go build something amazing! 🎮
About the Author
Michael Thompson
Senior Game Architect & Technical Director
Michael has designed and implemented game architectures for over 15 years, working on everything from mobile games to AAA console titles. He's passionate about sharing knowledge and helping developers build better, more maintainable game systems.