One region of the systems map, expanded: how a fight starts, how the initiative queue decides who swings next, and how a single attack crosses the client–server boundary twice while a swappable Lua script does the actual damage math.
Combat is assembled entirely on the server before either client hears about it.
BattleProcessor::setupBattle() resolves the terrain and battlefield — a town siege forces the town's native terrain, an object like an abandoned mine can force its own; otherwise the map tile decides. BattleInfo::setupBattle() then builds the actual army layout from both sides. Both players are locked out of other actions (PlayerBlocked) and a CBattleQuery is pushed so the game won't move on until the fight resolves. The whole result is sent as one pack, BattleStart — the first and only moment a client learns a battle exists.
Entirely server-side, entirely BattleFlowProcessor. Who moves next is never a client decision.
reads the initiative queue via battleGetTurnOrder; regeneration ticks here too, once per round
clears dead-and-gone “ghost” stacks, pokes the turn timer
waits on player/AI input — unless morale, berserk, or a mind-control effect forces one automatically
action applied, this stack's turn is over
startNextRound() sends a BattleNextRound pack and the loop begins again — otherwise it just returns to getNextStack() for whoever is next in the queue.The one action worth tracing end to end — every other action type (spell, walk, retreat) follows the same request/response shape.
scripts/damage/damageCalculator.lua) and gets a DamageEstimation back. If no such script is loaded, calculateDmgRange() throws rather than falling back to a hardcoded formula — combat math has no C++ default.Before the blow lands, makeAttack() fires BEFORE_ATTACK/BEFORE_ATTACKED triggers — a reaction here can kill either side before the strike happens. After, it collects AFTER_ATTACK/AFTER_ATTACKED reactions (life drain, fire shield, death stare) but runs them ordered by priority rather than in a fixed sequence, which is how life drain heals before a fire shield burns the attacker down, and why a reflecting ability still answers a lethal blow while its owner is dying.
Two flavors of combat script, both Lua, both opt-in per entity — nothing here runs unless something asks for it.
config/scriptsCombat.json — applied in this order, every attack
damage/damageCalculatorthe base H3 formula — attack vs. defense, luck, death blowdamage/siegeWeaponballista, catapult and first aid tent special casesdamage/magicElemental+ psychicElemental — elemental damage-type overridesdamage/vulnerableFromBack+ hatesTrait, revenge — creature-specific H3 quirksdamage/enemyAttackReduction+ damageReceivedCap — late clamps on the final numberattached to a specific bonus or ability, dispatched to a Lua method named after the event
lifeDrainheals the attacker for a share of the damage dealttransmutationturns the victim into another creature on deathsummonGuardiansspawns guardian units around the bearerenchantedapplies a spell's effects as a standing abilityarrowTowerDamagecomputes siege tower damage from town buildingsLuaCombatEventScript::handlesEvent() is what makes these opt-in — a script only answers to the one event type (WAIT, ROUND_START, AFTER_ATTACK, …) its bonus was registered for, dispatching to the matching Lua method by name (WAIT → onWait). Nothing is visited by default; a creature ability that doesn't reference one of these scripts never touches Lua at all.
Battle code never reimplements “is this unit immune / lucky / afraid” — it asks the bonus graph.
CBattleInfoEssentials and CUnitState are the query points: hasBonusOfType() and valOfBonuses() answer spell immunity checks, morale rolls (tryActivateMoralePenalty, rollGoodMorale), berserk, and regeneration amounts, all by walking the same DAG of nodes and propagators used everywhere else in the engine. Battle just consumes it here — the bonus system itself is its own map, coming later.
BattleResultProcessor turns a finished fight back into persistent state.
endBattle() tallies experience for the winning side, fills in a BattleResult (casualties, loot, who fled or surrendered), and once both sides have acknowledged it via endBattleConfirm(), sends BattleResultAccepted. That's the pack that folds the outcome back into the persistent CGameState — armies lose their dead, heroes gain experience and artifacts, and the CBattleQuery that had blocked both players since the opening pack is finally popped.