Combat systems are the backbone of many games:
RPGs, action games, shooters, platformers, MOBAs, and roguelikes.
This chapter explains how to implement real combat architecture in Lua:
- Entity stats & modifiers
- Damage pipeline
- Hitboxes & hurtboxes
- Buffs, debuffs, DOTs, HOTs
- Cooldowns & cast times
- Skill/ability system
- Combat logs & replay
- Network-sync-friendly updates
This is a complete combat architecture, not a toy example.
1. Entity Stats (Base + Modifiers)
Stats usually come from:
- base stats
- equipment
- buffs
- passive bonuses
- level scaling
- temporary effects
We design a stat system using:
- Base values
- Flat modifiers
- Multiplicative modifiers
1.1 Stat Object
local Stat = {}
Stat.__index = Stat
function Stat.new(base)
return setmetatable({
base = base or 0,
flat = 0,
mult = 1.0,
}, Stat)
end
function Stat:value()
return (self.base + self.flat) * self.mult
end
return Stat
1.2 Example: Player Stats
local hp = Stat.new(100)
local atk = Stat.new(12)
atk.flat = atk.flat + 5 -- item bonus
atk.mult = atk.mult * 1.2 -- buff
print("Attack =", atk:value()) -- 20.4
This math model is used in:
- Diablo
- Path of Exile
- Genshin Impact
- Monster Hunter
- WoW
2. Stats Component for Entities
local Stats = {}
Stats.__index = Stats
function Stats.new(cfg)
local self = setmetatable({}, Stats)
self.hp = Stat.new(cfg.hp)
self.attack = Stat.new(cfg.attack)
self.def = Stat.new(cfg.def)
return self
end
function Stats:current_hp()
return self.hp:value()
end
return Stats
Attach to an entity:
enemy:add("stats", Stats.new({hp=80, attack=10, def=2}))
3. Damage Pipeline (Industry Standard)
A proper damage pipeline handles:
- attacker stats
- skill modifiers
- hitbox/hurtbox
- defense
- critical hits
- elemental multipliers
- buffs & debuffs
- clamping to min/max
- post-processing callbacks
We’ll build a minimal version.
3.1 Damage Pipeline Processor
local Damage = {}
function Damage.compute(attacker, target, skill)
local atk = attacker.stats.attack:value()
local def = target.stats.def:value()
local base = atk * skill.power
local final = base - def
if final < 1 then final = 1 end
-- critical?
if math.random() < skill.crit_chance then
final = final * skill.crit_mult
end
return math.floor(final)
end
return Damage
3.2 Example skill data
local slash = {
power = 1.5,
crit_chance = 0.1,
crit_mult = 2.0,
}
3.3 Apply damage
local dmg = Damage.compute(attacker, target, slash)
target.stats.hp.base = target.stats.hp.base - dmg
print("damage:", dmg)
4. Hitboxes & Hurtboxes
Two common models:
Discrete collision
For turn-based or tile games.
Continuous collision
For action/MOBA/platformers.
We’ll implement continuous AABB (axis-aligned bounding box) overlap.
4.1 Hitbox Module
local Hitbox = {}
function Hitbox.overlaps(a, b)
return not (
a.x + a.w < b.x or
b.x + b.w < a.x or
a.y + a.h < b.y or
b.y + b.h < a.y
)
end
return Hitbox
4.2 Example check
local hb1 = {x=0, y=0, w=32, h=32}
local hb2 = {x=20, y=10, w=32, h=32}
if Hitbox.overlaps(hb1, hb2) then
print("Hit!")
end
5. Buffs & Debuffs
Buffs apply:
- flat modifiers
- multiplicative modifiers
- event hooks
- DOT (damage over time)
- HOT (heal over time)
5.1 Buff Structure
local Buff = {}
Buff.__index = Buff
function Buff.new(cfg)
return setmetatable({
duration = cfg.duration,
elapsed = 0,
apply = cfg.apply,
remove = cfg.remove,
tick = cfg.tick,
}, Buff)
end
function Buff:update(entity, dt)
self.elapsed = self.elapsed + dt
if self.tick then
self.tick(entity, dt)
end
return self.elapsed >= self.duration
end
return Buff
5.2 Buff Manager
local BuffManager = {}
BuffManager.__index = BuffManager
function BuffManager.new()
return setmetatable({list = {}}, BuffManager)
end
function BuffManager:add(entity, buff)
table.insert(self.list, {entity=entity, buff=buff})
if buff.apply then buff.apply(entity) end
end
function BuffManager:update(dt)
local remaining = {}
for _, item in ipairs(self.list) do
if not item.buff:update(item.entity, dt) then
table.insert(remaining, item)
else
if item.buff.remove then item.buff.remove(item.entity) end
end
end
self.list = remaining
end
return BuffManager
5.3 Example buffs
Attack boost buff
local atk_buff = Buff.new({
duration = 3,
apply = function(e) e.stats.attack.flat = e.stats.attack.flat + 5 end,
remove = function(e) e.stats.attack.flat = e.stats.attack.flat - 5 end,
})
Damage over time (burn)
local burn = Buff.new({
duration = 2,
tick = function(e, dt)
e.stats.hp.base = e.stats.hp.base - 5 * dt
end
})
6. Cooldowns & Cast Times
Every modern game wants this:
- abilities with cooldown
- abilities with cast bar
- skills interrupted mid-cast
6.1 Ability Base Class
local Ability = {}
Ability.__index = Ability
function Ability.new(cfg)
return setmetatable({
name = cfg.name,
cooldown = cfg.cooldown,
cast = cfg.cast or 0,
on_cast = cfg.on_cast,
on_fire = cfg.on_fire,
timer = 0,
}, Ability)
end
function Ability:use(entity)
if self.timer > 0 then return false end
if self.cast > 0 then
entity.casting = {ability=self, time=self.cast}
if self.on_cast then self.on_cast(entity) end
else
self:on_fire(entity)
end
self.timer = self.cooldown
return true
end
function Ability:update(dt)
if self.timer > 0 then
self.timer = math.max(0, self.timer - dt)
end
end
return Ability
6.2 Example abilities
Fireball
local fireball = Ability.new({
name = "fireball",
cooldown = 3,
cast = 1.2,
on_cast = function(e)
print("Casting fireball...")
end,
on_fire = function(e)
print("Fireball launched!")
end
})
6.3 Cast-bar update (FPS loop)
if entity.casting then
entity.casting.time = entity.casting.time - dt
if entity.casting.time <= 0 then
entity.casting.ability:on_fire(entity)
entity.casting = nil
end
end
7. Combat Log System (Replay-Friendly)
Log-based engines allow:
- replay
- rollback
- debugging
- analytics
7.1 Combat Log
local Log = {entries = {}}
function Log.record(event, data)
table.insert(Log.entries, {event=event, data=data})
end
return Log
7.2 Log damage
Log.record("damage", {
target = target.id,
amount = dmg,
skill = "slash"
})
8. Network-Sync-Friendly Combat (Authoritative Design)
Combat must be deterministic when possible.
Key rules:
- Use integers for damage
- Do not use
math.random()unsafely - Move RNG to server only
- Keep combat log authoritative
- Always run
update(dt)deterministically
Lua’s simplicity helps keep gameplay deterministic.
9. Full Combat Demo (Everything Together)
This is a full combat round, combining:
- stats
- damage
- hitboxes
- abilities
- buffs
- logging
local attacker = Entity.new(1)
local target = Entity.new(2)
attacker:add("stats", Stats.new({hp=100, attack=20, def=2}))
target:add("stats", Stats.new({hp=80, attack=12, def=5}))
local slash = {power=1.3, crit_chance=0.1, crit_mult=2}
if Hitbox.overlaps(attacker.hb, target.hb) then
local dmg = Damage.compute(attacker, target, slash)
target.stats.hp.base = target.stats.hp.base - dmg
BuffManager:add(target, burn)
Log.record("hit", {from=1, to=2, dmg=dmg})
end
This is the foundation of many real combat engines.
10. Summary of Chapter 5
You now understand:
- Robust stat systems
- Modifiers (flat/multiplicative)
- Damage pipelines
- AABB hit detection
- Buff/debuff architecture
- DOT/HOT mechanics
- Ability cooldowns
- Cast-bar system
- Combat logging
- How to compose combat logic cleanly
This is a real, production-ready combat architecture that can power an RPG, ARPG, action game, strategy game, or MOBA in Lua.
Keep Reading
Follow the engineering thread
Get the next practical Birdor note, or browse the archive for related systems, tooling, and architecture work.