NPC battle AI (group orders)#
Summary#
Non-player units in tactical combat are controlled by the same group-order AI
as human-controlled sides. There is no separate lotr2_combat_ai_* or noble-personality
battle module. Each army is split into groups (one per spawn batch / regroup
cluster); every group runs a periodic tick handler that picks primitive movement
orders (approach, hold, rally, rout, siege lane, etc.). Individual figures then
execute paths and combat through the shared figure state machine.
NPC sides never receive map-click orders; human sides may override the selected group via the battle UI while other groups continue under AI.
Control model#
Frame loop#
When lotr2_game_active_view == 2 and lotr2_game_screen_mode < 0x30, each frame
runs (in order):
lotr2_combat_sync_group_slot_counters— rebuild group member counts and flagslotr2_combat_tick_all_figures— per-figure unit-type and state handlerslotr2_combat_advance_projectiles_once— projectile steplotr2_combat_tick_all_groups— group AI dispatch and replan
flowchart TD
sync[sync_group_slot_counters]
fig[tick_all_figures]
proj[advance_projectiles_once]
grp[tick_all_groups]
sync --> fig --> proj --> grp
grp --> centroid[update_group_centroid]
centroid --> dispatch[Dispatch handler by order_type]
dispatch --> primitive[Primitive order: approach / stalk / hold / ...]
primitive --> move[move_group_to_tile]
move --> orderFig[order_figure_move_to per idle member]
orderFig --> fig
Human vs NPC#
| Aspect | Local human side | NPC / unattended side |
|---|---|---|
| Group AI | Runs when player_control_flag (+0x01) == 0 |
Same |
| Map orders | Click → lotr2_combat_move_group_to_tile(selected_group, x, y) |
None |
| Selected group | DAT_0056432c (local player) |
N/A |
| Regroup | UI → lotr2_combat_assign_group_orders(local_side) |
Automatic at spawn only |
Human map orders set goal tiles directly; they do not disable AI on other groups.
Campaign map AI (lotr2_map_ai_*) and auto-calculate
(auto-calculation.md) are separate layers.
Multiplayer#
Battle move orders sync via DirectPlay messages (opcode 0x43 tile click,
0x4f group selection). Host authority and full order replication are not
fully documented — see Open questions.
Group lifecycle#
Battle start#
lotr2_combat_init_battle_state:
- Clear all figure, projectile, and group slots
- Spawn attacker (
lotr2_combat_spawn_army_open_fieldor defender siege spawn) - Spawn defender
lotr2_combat_sync_group_slot_counters
Each lotr2_combat_spawn_figure_batch call allocates one group via
lotr2_combat_alloc_group_slot(player_id, player_control_flag) and assigns an
initial order_type from unit type (see Spawn order assignment).
Regroup#
lotr2_combat_assign_group_orders(side) clusters figures sharing a group index,
then for each cluster:
- Ranged figures (
ranged_fire_flag != 0) →order_type = 1 - Melee figures →
order_type = 3
Siege engine groups may be reassigned by lotr2_combat_set_group_siege_order_type
when issuing a move that mixes infantry with engines.
Replan#
Each group maintains replan_timer (initial 600, decrements every frame).
When it reaches 0 it resets to 600 and, if lotr2_combat_group_may_replan_orders
returns true:
- If formation not blocked → scatter orders, or ram-goal orders for battering-ram groups on large castles; else formation grid orders
- Sets
formation_blocked = 0
Replan is suppressed while any member has non-zero move-target field or while
routing_flag is set.
Order type catalog#
order_type (+0x08 in lotr2_battle_group)
selects the handler from one of three pointer tables depending on siege mode.
Handler dispatch tables (retail LORDS2.EXE)#
Decoded from PTR_LAB_004f47xx @ image base 0x400000:
Open field (siege_mode == 0) — PTR_LAB_004f4718, dispatched when order_type < 5:
| Index | Handler | Address |
|---|---|---|
| 0 | lotr2_combat_group_tick_stub_knight |
0x0049b65c |
| 1 | lotr2_combat_group_tick_order_melee |
0x0049b667 |
| 2 | lotr2_combat_group_tick_order_ranged |
0x0049b967 |
| 3 | lotr2_combat_group_tick_order_defend |
0x0049bcb4 |
| 4 | lotr2_combat_group_tick_order_defend |
0x0049bcb4 |
Siege attacker (siege_mode != 0, defender row flag == 0) — PTR_LAB_004f4758, order_type < 10:
| Index | Handler | Address |
|---|---|---|
| 0 | stub knight | 0x0049b65c |
| 1 | lotr2_combat_group_tick_order_knight_capture |
0x0049e6a8 |
| 2 | lotr2_combat_group_tick_order_defend_lane |
0x0049e8d9 |
| 3 | lotr2_combat_group_tick_order_defend_wall |
0x0049ead0 |
| 4 | lotr2_combat_group_tick_order_hold_line |
0x0049ed78 |
| 5–7 | stub knight | 0x0049b65c |
| 8 | lotr2_combat_group_tick_order_seek_flag |
0x0049eebc |
| 9 | lotr2_combat_group_tick_stub_tower |
0x0049e845 |
Siege defender (defender row flag != 0) — PTR_LAB_004f4730, order_type < 9:
| Index | Handler | Address |
|---|---|---|
| 0 | stub knight | 0x0049b65c |
| 1 | lotr2_combat_group_tick_order_catapult |
0x0049d855 |
| 2 | lotr2_combat_group_tick_order_battering_ram |
0x0049dadb |
| 3 | lotr2_combat_group_tick_order_siege_tower |
0x0049dd98 |
| 4 | lotr2_combat_group_tick_order_capture_flag |
0x0049e03d |
| 5 | lotr2_combat_group_tick_order_tower_wall_scan |
0x0049e1e9 |
| 6 | lotr2_combat_group_tick_order_ram_wall_scan |
0x0049e3e9 |
| 7 | lotr2_combat_group_tick_order_gatehouse |
0x0049e5cc |
| 8 | stub knight | 0x0049b65c |
Note: Handler function names describe behavior; the numeric order_type stored
at spawn/regroup does not always match the semantic role (e.g. regroup assigns
order_type = 1 to ranged infantry, which dispatches the function named ..._melee).
Spawn order assignment#
Initial order_type from lotr2_combat_spawn_figure_batch (param_2 = unit type):
| Unit type | ID | Initial order_type |
|---|---|---|
| Peasant | 0 | 2 |
| Archer | 1 | 1 |
| Swordsman | 2 | 3 |
| Maceman | 3 | 3 |
| Pikesman | 4 | 2 |
| Crossbowman | 5 | 1 |
| Knight | 6 | 4 |
| Catapult | 7 | 5 |
| Battering ram | 8 | 6 |
| Siege tower | 9 | 7 |
| Moat / ladder | 10 | 8 |
Siege defender ranged override: when siege_mode != 0, defender spawn row
(param_4 == 0), and (spawn_batch_index & 3) == 0, archer/crossbow groups get
order_type = 9 instead of 1 (dispatches attacker-table stub_tower on siege
maps — see open questions).
Siege order reassignment#
lotr2_combat_set_group_siege_order_type scans group members in priority order:
- Catapult → 5
- Battering ram → 6
- Siege tower → 7
- Moat/ladder → 8
- Else if any ranged → 1, else → 3
Strength ratio#
Every 100 frames, lotr2_combat_compute_unit_score updates global
strength_delta (DAT_0050be20):
- For each live figure, compute weighted headcount:
- Peasant ×1, Archer ×2, Swordsman ×3, Maceman ×3, Pikesman ×2, Crossbow ×2, Knight ×4
- Siege types use base headcount (no extra multiplier in this pass)
- Sum into side A (figure
sidebyte == 0) and side B (side == 1) strength_delta = (sideA * 100 / sideB) - 100(0 = even strength)
AI compares strength_delta to threshold 15:
| Context | Threshold global | Meaning |
|---|---|---|
| Open field | DAT_00580ba8 (= 15) |
Win/lose gate for melee/ranged/defend |
| Siege defender handlers | DAT_00582d60 (= 15) |
Same gate; also triggers oil spill + rout |
strength_delta > 15— side is winning → aggressive orders (approach, focus fire, copy enemy center)strength_delta ≤ 15— even or losing → cautious orders (hold, rally, retreat, rout, siege lane reposition)
Group AI timing#
For each active group slot (1..0x50):
- Skip if slot empty or
player_control_flag != 0 lotr2_combat_update_group_centroid- Dispatch handler from the appropriate
PTR_LABtable - Decrement
replan_timer; at 0 → try replan
Within each handler:
- Increment
decision_timer(+0x18) every frame - When
decision_timer > 199(defend_wall uses > 99) andbusy_flag == 0: - Reset
decision_timerto 0 - Evaluate behavior branch (uses
phase_counter,strength_delta, enemy search) - Increment
phase_counter
Enemy targeting#
Nearest enemy group#
lotr2_combat_find_nearest_enemy_group(group, max_dist, min_members):
- Iterates active groups with different
player_id(+0x00) - Requires enemy
member_count >= min_members - Chebyshev distance between centroids ≤
max_dist - Returns closest group index, or 0 if none
Common call patterns:
| Handler | max_dist | min_members |
|---|---|---|
| Melee | 0x50 (80) | 1 |
| Ranged rout check | 8 | 3 |
| Defend | 9 | 3 |
| Defend lane | 10 | 0 |
Focus fire#
lotr2_combat_group_try_focus_fire(target_group):
- For each member,
lotr2_combat_find_nearest_enemy_in_range(0x28, figure, target_group)(range 40 tiles) - On hit: figure state → 0x11, store target figure index
- Returns 0 if any member failed to find a target in range
Primitive orders#
Primitive orders set group goal tiles and call lotr2_combat_move_group_to_tile,
which paths idle members via lotr2_combat_order_figure_move_to.
| Primitive | Function | Behavior |
|---|---|---|
| Approach | lotr2_combat_group_order_approach_enemy |
Goal = halfway between own and enemy centroids (per-axis delta / 2, min axis delta > 2 to move on that axis) |
| Stalk | lotr2_combat_group_order_stalk_enemy |
Move ±2 tiles along dominant axis toward enemy centroid |
| Hold | lotr2_combat_group_order_hold_centroid |
Goal = current centroid |
| Copy center | lotr2_combat_group_order_copy_enemy_center |
Goal = enemy group centroid |
| Retreat | lotr2_combat_group_order_retreat_from_target |
Goal away from tracked target coords |
| Rally | lotr2_combat_group_order_goto_rally_point |
Goal from spawn rally table by index |
| Siege lane | lotr2_combat_group_order_goto_siege_lane |
Param selects lane index (0, 1, or 2) |
| Focus (group) | lotr2_combat_group_order_focus_nearest_enemy |
Move whole group toward nearest in-range enemy figure tile |
| Rout | lotr2_combat_group_rout_figures |
All members → figure state 0x08 |
| No-op | lotr2_combat_group_tick_noop |
No order issued |
Per-order state machines#
The following rules use phase_counter (phase below) incremented once per
decision cycle. target = locked enemy group from +0x0A when target_lock_timer > 0.
Order index 1 — lotr2_combat_group_tick_order_melee#
Nearest enemy: find_nearest_enemy_group(self, 80, 1).
Winning (strength_delta > 15):
- If no valid target → by phase:
< 10noop;< 0x12approach;< 0x15hold; else approach if enemy exists - If valid target → focus fire
Losing (strength_delta ≤ 15):
- If siege assault timer (
DAT_005930dc) == 0: - No target → every 4 phases (
phase & 3 == 0) goto siege lane 0, else retreat - Valid target → focus fire
- Else → approach nearest enemy
- If
kill_counter > 10and target valid and stalk flags clear → stalk enemy
Order index 2 — lotr2_combat_group_tick_order_ranged#
Winning:
- No target: phase
< 2noop;< 10goto rally point; else rout - Valid target: copy enemy center
Losing:
- Valid target: increment stalk counter; when castle size threshold met or wall timer expired → set assault timer and stalk; else decrement assault timer
- If assault timer == 0: every 5 phases goto siege lane 2 (if lane flag clear)
- If enemy within 8 tiles (min 3 members): rout
- Else if assault timer > 0: rout
Order index 3 — lotr2_combat_group_tick_order_defend#
Same structure as ranged with these differences:
- Rally window extends to phase
< 0x0D(13) before rout - Siege lane index 1 instead of 2
- Enemy search distance 9 instead of 8
Order index 5 — catapult (siege defender table)#
Decision every 200 ticks. Cycles through siege anchors, wall breach / entry
alternation, focus fire, and capture-tile when gate is down. Uses global counters
DAT_00502a18, DAT_00502a2c, DAT_00573c04 to phase between bombardment and advance.
Order index 6 — battering ram#
Alternates goto_siege_entry and find_wall_breach in phase bands (< 0x14, < 0x1F,
< 0x28, < 0x33, < 0x3C, < 0x47, else) when gate intact.
Order index 7 — siege tower#
Similar ram-style alternation between entry and breach; when gate down, cycles
capture tile / hold centroid based on DAT_00502a30.
Order index 4 — capture flag (defender table)#
When wall clock exceeds threshold and gate closed, marks capture attempt globals.
Rotates between lane slot advance and goto_siege_anchor.
Siege attacker orders 2–4, 8 (defend lane, defend wall, hold line, seek flag)#
Common pattern on strength_delta > 15: spawn oil spill tiles, rout group.
Otherwise: copy enemy center, goto capture tile, lane slot rotation, or
goto_enemy_on_wall depending on wall proximity and castle size globals
(DAT_00552150, DAT_005643bc, DAT_0055c018).
defend_wall uses a 100-tick decision interval instead of 200.
Figure autonomy#
After group orders set goals, lotr2_combat_tick_all_figures dispatches
PTR_LAB_004f46d0[figure.state] for every live figure. NPC and human figures
share identical logic:
| State | Handler role |
|---|---|
| Walk / move | Path step via lotr2_combat_try_advance_figure_path |
| Melee engage | lotr2_combat_apply_melee_hit |
| Start melee | Scan adjacent enemy or find in range 5 |
| Chase | Pursue assigned target figure |
| Ranged fire | Spawn projectile |
| Rout (8) | Run away from battle |
Unit-type handlers (PTR_LAB_004f46a0) run first each figure tick for animation,
damage lookup, and siege-specific updates.
Routing and morale#
lotr2_combat_group_rout_figures— all members → state 8lotr2_combat_mark_side_infantry_routing(player_id)— infantry (type < 7) on that side rout and set grouprouting_flaglotr2_combat_rout_group_figures_except_melee— rout non-melee groups when not ranged-order andplayer_control_flag == 0
Data model#
docs/structs/lotr2_battle_group.md— 0x34-byte group slotdocs/structs/lotr2_battle_figure.md— 0x1b0-byte figure slot
Evidence#
| Symbol | Address | Role |
|---|---|---|
lotr2_combat_tick_all_groups |
0x0049a0b5 | Group AI dispatcher |
lotr2_combat_compute_unit_score |
0x00490e96 | Strength delta update |
lotr2_combat_spawn_figure_batch |
0x00491113 region | Group alloc + initial order type |
lotr2_combat_assign_group_orders |
0x0048be59 | Regroup clustering |
lotr2_combat_set_group_siege_order_type |
0x00492634 | Siege engine order override |
lotr2_combat_group_tick_order_melee |
0x0049b667 | Melee group AI |
lotr2_combat_group_tick_order_ranged |
0x0049b967 | Ranged group AI |
lotr2_combat_group_tick_order_defend |
0x0049bcb4 | Defend group AI |
lotr2_combat_find_nearest_enemy_group |
0x0049f521 | Enemy group search |
lotr2_combat_group_try_focus_fire |
0x0049d2de | Focus fire primitive |
lotr2_combat_move_group_to_tile |
0x0048cab6 | Goal + path all members |
lotr2_combat_sync_group_slot_counters |
0x004907ec region | Pre-tick group rebuild |
lotr2_game_main_frame_tick |
0x004d49a0 | Battle sim orchestration |
PTR_LAB_004f4718 |
0x004f4718 | Open-field handler table |
PTR_LAB_004f4758 |
0x004f4758 | Siege attacker handler table |
PTR_LAB_004f4730 |
0x004f4730 | Siege defender handler table |
Architecture index: docs/architecture/combat/MODULE.md.
Confidence#
likely — core loop, handler tables, strength formula, and melee/ranged/defend
branches are traced in retail LORDS2.EXE.c. Siege lane / rally point table
addresses and MP order authority need further confirmation.
Upgrade to confirmed after: playtesting side-by-side with retail, dumping rally /lane coordinate tables, and documenting MP opcodes end-to-end.
Interop notes#
- Tactical battle AI state is runtime-only; not persisted in save games mid-fight
- Multiplayer battle orders use opcodes
0x43/0x4f; full host sync model TBD - Skirmish uses the same engine (
docs/skirmish/battle-flow.md)
Open questions#
- Index 0 (
stub_knight) behavior — appears unused at spawn; may guard empty groups - Exact rally point table VAs and lane slot coordinates
- Why spawn assigns archers
order_type = 1(melee handler) while regroup uses the same mapping for ranged — confirm ranged fire is entirely figure-state driven - Siege defender ranged override to
order_type = 9vs handler table mismatch on defender path - MP: host authority for
strength_deltaand group tick when both sides human