⚓ Seaworth Games

lib/battle · server/battles · client/battle

Battle System

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.

Starting a battle

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.

The turn loop

Entirely server-side, entirely BattleFlowProcessor. Who moves next is never a client decision.

getNextStack()

reads the initiative queue via battleGetTurnOrder; regeneration ticks here too, once per round

→

activateNextStack()

clears dead-and-gone “ghost” stacks, pokes the turn timer

→

choose action

waits on player/AI input — unless morale, berserk, or a mind-control effect forces one automatically

→

onActionMade()

action applied, this stack's turn is over

queue empty → startNextRound() sends a BattleNextRound pack and the loop begins again — otherwise it just returns to getNextStack() for whoever is next in the queue.

Anatomy of an attack

The one action worth tracing end to end — every other action type (spell, walk, retreat) follows the same request/response shape.

CLIENT BattleActionsController SERVER BattleActionProcessor SCRIPT LAYER damageCalculator.lua + patches MakeAction · BattleAction player targets a hex dispatchBattleAction → doAttackAction → makeAttack() calculateDmgRange() base formula, then ordered patches DamageEstimation applyBattleEffects — roll damage, resolve casualties BattleAttack · broadcast BattleStacksController plays it
An attack crosses the client–server line exactly twice — once as a request, once as the broadcast result — and in between, the server never computes damage itself. It hands the numbers to a registered script (core ships 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.

The script layer

Two flavors of combat script, both Lua, both opt-in per entity — nothing here runs unless something asks for it.

damageCalculator — one, with patches

config/scriptsCombat.json — applied in this order, every attack

  1. damage/damageCalculatorthe base H3 formula — attack vs. defense, luck, death blow
  2. damage/siegeWeaponballista, catapult and first aid tent special cases
  3. damage/magicElemental+ psychicElemental — elemental damage-type overrides
  4. damage/vulnerableFromBack+ hatesTrait, revenge — creature-specific H3 quirks
  5. damage/enemyAttackReduction+ damageReceivedCap — late clamps on the final number
combatEvent — many, opt-in

attached to a specific bonus or ability, dispatched to a Lua method named after the event

lifeDrainheals the attacker for a share of the damage dealt
transmutationturns the victim into another creature on death
summonGuardiansspawns guardian units around the bearer
enchantedapplies a spell's effects as a standing ability
arrowTowerDamagecomputes siege tower damage from town buildings

LuaCombatEventScript::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.

Where bonuses plug in

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.

Ending a battle

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.