Last modified: June 06, 2026
This article is written in: 🇺🇸
This document describes the RTS combat system in Standard of Iron, including:
RTS combat is coordinated by Game::Systems::CombatSystem, implemented in:
game/systems/combat_system.cpp
Combat-related side effects should flow through this system whenever possible. Normal unit attacks, siege and tower attacks, elephant trample damage, target selection, and auto-engagement all rely on the same combat query context and enemy-validation rules.
This shared path helps keep combat behavior consistent and prevents individual processors from implementing conflicting targeting or damage logic.
Each frame, CombatSystem::update() runs the following pipeline:
CombatSystem::update
|
|-- rebuild_combat_query_context
|-- process_hit_feedback
|-- process_combat_state
|-- process_attacks
|-- process_siege_specials
|-- process_elephant_specials
|-- AutoEngagement::process
The order matters:
CombatQueryContext is defined in:
combat_utils.h
It is rebuilt once at the beginning of each combat update and provides shared lookup data for combat processors.
The context contains:
units: alive unit entities that are not pending removal;entities_by_id: fast lookup of entities by target ID;unit_grid: spatial lookup for nearby non-building units;nearby_unit_ids: reusable scratch storage for range queries.Combat processors should receive and reuse this context instead of repeatedly calling:
World::get_entities_with<UnitComponent>()
Using the shared context:
All combat behaviors that select or damage targets should use the shared validation helper:
Combat::is_valid_enemy_unit(attacker_unit, target, allow_buildings)
This helper rejects targets that are:
UnitComponent;OwnerRegistry;allow_buildings == false.Avoid implementing direct owner checks in individual combat processors. Team and alliance rules are easy to handle incorrectly when duplicated.
Normal attacks are processed by:
combat_system/attack_processor.cpp
The process_attacks() processor handles:
SpecialAttackComponent;Resolved attacks should apply damage through:
Combat::deal_damage(world, target, damage, attacker_id)
This is the preferred damage entry point because it centralizes:
New attack behaviors should avoid modifying health directly unless there is a strong architectural reason to bypass the standard combat flow.
Siege weapons and defensive-building combat are handled by:
combat_system/siege_special_processor.cpp
This processor owns:
Catapults and ballistas share CatapultLoadingComponent for their loading state.
Their state machine is:
Idle -> Loading -> ReadyToFire -> Firing -> Idle
If a siege unit begins moving while loading or firing, its loading state is reset. This prevents the unit from firing at a previously locked target after changing position.
Defense towers select the nearest valid enemy within range.
They may attack:
They ignore ordinary buildings.
Tower arrow spread is generated deterministically from entity IDs, ensuring that repeated runs produce stable visual results.
Elephant-specific combat behavior is handled by:
combat_system/elephant_special_processor.cpp
This processor owns:
Panic state is stored in:
ElephantPanicComponent
ElephantComponent remains focused on combat statistics and charge/trample state.
Panic behavior does not create hidden movement targets. Instead, it influences the elephant's combat decisions directly.
Trample damage applies only to valid enemies.
Friendly and allied troops are rejected through:
Combat::is_valid_enemy_unit()
As a result, even a panicked elephant cannot damage units on its own side.
AutoEngagement runs after explicit attacks and special combat processors.
Its purpose is to allow eligible idle units to acquire nearby enemies without requiring a direct attack command.
Auto-engagement uses:
CombatQueryContext;A unit is not considered freely idle when it has:
Combat animation state is stored in:
CombatStateComponent
It is advanced by:
process_combat_state()
Transient hit feedback, such as hit flashes, is handled by:
process_hit_feedback()
Combat processors may spawn projectile, arrow, or impact visuals. However, final damage resolution should still go through:
Combat::deal_damage()
when the attack connects.
Combat visuals may appear random, but their variation should remain deterministic.
The current combat code uses hash-based values derived from entity IDs and target IDs for effects such as:
Avoid introducing:
std::random_device
or global:
std::rand()
into combat code. Non-deterministic randomness makes combat tests, debugging, and replay behavior harder to reason about.
Use the following approach when introducing new combat functionality:
I. Add a dedicated component when the behavior requires persistent state.
II. Add a processor under:
game/systems/combat_system/
III. Call the processor from CombatSystem::update():
IV. Use CombatQueryContext for entity lookup and range scanning.
V. Use Combat::is_valid_enemy_unit() for target validation.
VI. Use Combat::deal_damage() for damage application.
VII. Add focused tests under:
tests/systems/
Avoid creating a new top-level System for combat damage unless the behavior is genuinely outside the combat simulation. Separate damage systems tend to develop inconsistent targeting, validation, and damage rules.
The RPG commander combat resolver has its own implementation under:
game/systems/rpg_combat_system/
Although it is related to combat, it represents a different abstraction from RTS unit combat and does not share the same processing path.
Projectile movement remains the responsibility of:
ProjectileSystem
Arrow trail visuals remain the responsibility of:
ArrowSystem
The combat system decides when these effects are created. The projectile and arrow systems own their subsequent simulation and rendering-facing data.