Lua for Game Development — Chapter 5: Combat Systems, Hitboxes, Stats, Buffs, and Abilities
Leeting Yan
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.