Skip to content

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):

  1. lotr2_combat_sync_group_slot_counters — rebuild group member counts and flags
  2. lotr2_combat_tick_all_figures — per-figure unit-type and state handlers
  3. lotr2_combat_advance_projectiles_once — projectile step
  4. lotr2_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:

  1. Clear all figure, projectile, and group slots
  2. Spawn attacker (lotr2_combat_spawn_army_open_field or defender siege spawn)
  3. Spawn defender
  4. 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:

  1. Catapult → 5
  2. Battering ram → 6
  3. Siege tower → 7
  4. Moat/ladder → 8
  5. Else if any ranged → 1, else → 3

Strength ratio#

Every 100 frames, lotr2_combat_compute_unit_score updates global strength_delta (DAT_0050be20):

  1. For each live figure, compute weighted headcount:
  2. Peasant ×1, Archer ×2, Swordsman ×3, Maceman ×3, Pikesman ×2, Crossbow ×2, Knight ×4
  3. Siege types use base headcount (no extra multiplier in this pass)
  4. Sum into side A (figure side byte == 0) and side B (side == 1)
  5. 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 ≤ 15even or losing → cautious orders (hold, rally, retreat, rout, siege lane reposition)

Group AI timing#

For each active group slot (1..0x50):

  1. Skip if slot empty or player_control_flag != 0
  2. lotr2_combat_update_group_centroid
  3. Dispatch handler from the appropriate PTR_LAB table
  4. Decrement replan_timer; at 0 → try replan

Within each handler:

  • Increment decision_timer (+0x18) every frame
  • When decision_timer > 199 (defend_wall uses > 99) and busy_flag == 0:
  • Reset decision_timer to 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):

  1. If no valid target → by phase: < 10 noop; < 0x12 approach; < 0x15 hold; else approach if enemy exists
  2. If valid target → focus fire

Losing (strength_delta ≤ 15):

  1. If siege assault timer (DAT_005930dc) == 0:
  2. No target → every 4 phases (phase & 3 == 0) goto siege lane 0, else retreat
  3. Valid target → focus fire
  4. Else → approach nearest enemy
  5. If kill_counter > 10 and target valid and stalk flags clear → stalk enemy

Order index 2 — lotr2_combat_group_tick_order_ranged#

Winning:

  • No target: phase < 2 noop; < 10 goto 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 8
  • lotr2_combat_mark_side_infantry_routing(player_id) — infantry (type < 7) on that side rout and set group routing_flag
  • lotr2_combat_rout_group_figures_except_melee — rout non-melee groups when not ranged-order and player_control_flag == 0

Data model#

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 = 9 vs handler table mismatch on defender path
  • MP: host authority for strength_delta and group tick when both sides human