Skip to content
TAURION
08Combat

Combat

Targeting, damage, armour and shields, safe zones and friendlies.

Source of truth. Every rule below is taken from the Game State Processor (GSP) C++ source. Citations are given as file:line. Display names are the ones shown in the official client; internal codes are shown in backticks. Numbers come from the config protos under proto/roconfig/ and from code constants — none are invented.

In plain terms (for players): you never click "attack" in Taurion. You drive your vehicle — say a Red rv st (display name Raider) or a Green gv s (Mantis) — near an enemy, and if you have a weapon fitment bolted on (a fitment is any equippable module, e.g. a lf beam / Light Tachyon Beam), the game automatically picks the nearest enemy and fires every block (~one move per few seconds). All you control is positioning, what you fit, and whether you duck into a building or a safe zone (both make you untouchable). Everything below is the exact, deterministic rule set the server runs — the same input always produces the same outcome, which is why no two clients ever disagree about who died.

Combat in Taurion is fully deterministic and automatic. Players do not issue attack commands. Once a vehicle (character) or building has weapons fitted, the GSP selects targets and deals damage every block on its own. The only player influence is: where the vehicle is, what is fitted to it, whether it is inside a building (immune), and whether it is inside a safe zone (immune). This document describes exactly what the engine does.

A fighter is any entity that can fight — a vehicle (the engine calls it a "character") or a building. There are two fighter entity types, defined by proto::TargetId::Type (proto/combat.proto:37-44):

Type code Enum value Meaning
TYPE_BUILDING 1 A building on the map
TYPE_CHARACTER 2 A vehicle (character) on the map

A target is identified by a TargetId { type, id }. The numeric type values are chosen so that (type, id) pairs sort in the exact order used by DB queries and the target finder (proto/combat.proto:40-43, src/combat.hpp:39-62). Buildings always sort before characters.


1. Per-block combat processing order#

Combat is split across two blocks. Target finding runs at the end of a block; damage is applied at the start of the next. This is the single most important timing fact for UX designers: the target you see in the state is who the fighter will shoot next block — there is a one-block "aim, then fire" cycle.

PXLogic::UpdateState (src/logic.cpp:97-126) runs each block in this order:

  1. mvProc.ProcessAdmin / mvProc.ProcessAll — apply player moves (incl. fitting, spawning, entering/leaving requests). src/logic.cpp:103-104
  2. fame.GetDamageLists().RemoveOld(damage_list_blocks) — expire stale damage-list entries. src/logic.cpp:106-107
  3. AllHpUpdates(...) — the whole combat resolution (see below). src/logic.cpp:109
  4. ProcessAllOngoings — construction, prospecting, blueprint copies, etc. :110
  5. ProcessAllMining :112
  6. ProcessAllMovement :113
  7. ProcessEnterBuildings — vehicles enter buildings after movement but before target finding, so a vehicle that reaches a building is safe that same block. src/logic.cpp:115-119
  8. FindCombatTargets(...) — pick targets for next block. src/logic.cpp:121

AllHpUpdates (src/combat.cpp:1386-1399) runs three coupled steps in order:

  1. DealCombatDamage → returns the set of dead fighters (src/combat.cpp:1390).
  2. For each dead character, fame.UpdateForKill (fame transfer). src/combat.cpp:1392-1393
  3. ProcessKills → delete dead entities, drop loot, refund, etc. src/combat.cpp:1395-1396
  4. RegenerateHP → regenerate armour and shield for every fighter. src/combat.cpp:1398

Consequence: damage is dealt using the targets that were chosen at the end of the previous block. A vehicle that moved out of range or entered a building/safe zone after targeting will still not be hit if it left, because targeting will have been re-run; but damage uses targets locked in last block. Designers should present the "current target" indicator as a next-block intent.


2. Target selection (FindCombatTargets)#

Entry point FindCombatTargets (src/combat.cpp:401-406) → TargetFindingProcessor::ProcessAll (src/combat.cpp:390-397), which iterates only fighters that have attacks via fighters.ProcessWithAttacks (so fighters with no weapons are never given a target).

For each fighter, SelectTarget (src/combat.cpp:352-374) is computed, then Finalise (src/combat.cpp:376-388) writes the chosen target.

2.1 Who is eligible to be a target#

ProcessCombatTargets (src/combat.cpp:139-169) wraps the low-level L1-distance target finder and applies these filters:

  • Range: only fighters within L1 (hex Manhattan) distance ≤ the (modified) attack range are considered. src/combat.cpp:152 and SelectNormalTarget (src/combat.cpp:280-309).
  • Faction / friend-or-foe: the finder is told whether to look for enemies, friendlies, or (with mentecon) both (src/combat.cpp:148-152). Faction is read from f.GetFaction(). Same-faction fighters are friendlies; different-faction are enemies. Ancient (neutral) buildings are a faction of their own and are enemies to everyone (see test TargetSelectionTests.WithBuildings, combat_tests.cpp:428-465).
  • No-combat zone: any candidate standing on a no-combat safe-zone tile is ignored (src/combat.cpp:156-162). See §6.
  • Self: a fighter never targets itself (src/combat.cpp:164-165).
  • Inside a building: vehicles inside buildings are not in the on-map fighter set, so they cannot be targeted and are not given targets. Entering a building clears the vehicle's existing target (test TargetSelectionTests.InsideBuildings, combat_tests.cpp:467-492).

2.2 Normal (enemy) target selection — closest, then random tie-break#

SelectNormalTarget (src/combat.cpp:273-317):

  1. Attack range = the fighter's longest enemy attack range (GetAttackRange(false)), which is the max over all non-friendly attacks of range (or area if the attack has no range) — database/combat.cpp:142-165. If the fighter has no enemy attacks the value is NO_ATTACKS (-1) and no target is selected (src/combat.cpp:281-285).
  2. The range is modified by the fighter's range modifiers (mod.range, §5): range = mod.range(range) (src/combat.cpp:287).
  3. Among all valid enemies in range, keep only those at the minimum L1 distance (src/combat.cpp:291-309).
  4. From that closest set, pick one uniformly at random (Finalise, src/combat.cpp:382-385). This costs exactly one RNG roll.

Key facts:

  • The fighter always picks the single closest enemy (test ClosestTarget, combat_tests.cpp:292-323). Ties are broken uniformly at random over all equally-close enemies (test Randomisation, combat_tests.cpp:589-633).
  • Range 0 is legal — it means "only enemies on the same tile" (test ZeroRange, combat_tests.cpp:325-351; proto note proto/combat.proto:98-99).
  • Multiple attacks: the longest range determines who gets targeted; individual attacks then only fire if their own range reaches (see §3.3). Test MultipleAttacks (combat_tests.cpp:540-563) and OnlyAreaAttacks (combat_tests.cpp:565-587).
  • If a fighter only has area attacks (no range), area is used as its targeting range (database/combat.cpp:152-158); it still picks a normal target so the AoE has a centre.

2.3 Friendly-target flag#

SelectFriendlyTargets (src/combat.cpp:319-350) sets a single boolean hasFriendlyTarget. It uses the fighter's longest friendly attack range (GetAttackRange(true)) and merely checks whether any friendly is in range. It does not pick a specific friendly; friendly attacks are always AoE-style and hit everyone in the area at fire time (§3.4). Tests: FriendlyAttacks (combat_tests.cpp:353-387).

2.4 Final write#

Finalise (src/combat.cpp:376-388):

  • If there were enemy targets, set one random one as the target.
  • Otherwise ClearTarget().
  • Always set the friendly-target flag.

2.5 RNG accounting for targeting#

  • No random numbers are used for fighters with no attacks, only friendly attacks, in a building, in a safe zone, or with no enemy in range (test NoRandomNumbersRequested, combat_tests.cpp:688-736).
  • Exactly one roll is used per fighter that has at least one enemy in range — even if there is only a single candidate or only an area attack (test RandomNumbersRequested, combat_tests.cpp:738-758).

3. Attack mechanics (DealCombatDamage)#

Entry point DealCombatDamage (src/combat.cpp:1032-1039) → DamageProcessor::Process (src/combat.cpp:895-1028). It returns the sorted set of all dead fighters.

3.1 Damage roll (min/max)#

RollAttackDamage (src/combat.cpp:573-583):

minDmg = mod.damage(attack.min)
maxDmg = mod.damage(attack.max)            # min ≤ max enforced (CHECK)
dmg    = minDmg + rnd.NextInt(maxDmg - minDmg + 1)   # uniform, both-inclusive

The roll is uniform over [min, max] inclusive (proto proto/combat.proto:118-121; test RandomisedDamage, combat_tests.cpp:1287-1323). mod.damage is the attacker's damage stat modifier (low-HP boosts + fitment damage bonus; §5).

One roll per attack, shared by all AoE victims. For an area attack, the same damage number is applied to every target hit this round (test AreaAttacks, combat_tests.cpp:893-929; RNG counts in DamagingRandomRollsTests, combat_tests.cpp:2126-2334):

Attack kind Damage rolls per round
Directed attack, target in range 1
Directed attack, target out of range 0 (skipped)
Area attack 1 (always, even with 0 targets)
Pure-effect attack (no damage) 0
Self-destruct 1 per self-destruct entry

3.2 Hit / miss chance#

Base hit chance is purely target size vs weapon size. BaseHitChance (src/combat.cpp:410-424):

if (no target_size OR no weapon_size)          -> 100%
if (target_size >= weapon_size)                -> 100%
else                                           -> (target_size * 100) / weapon_size   (integer)

So a small target dodges a heavy (large-weapon_size) weapon. Tests BaseHitChanceTests (combat_tests.cpp:762-807): e.g. target 9 / weapon 10 → 90%; target 1 / weapon 100 → 1%; target 1 / weapon 101 → 0%.

Then AttackHitsTarget (src/combat.cpp:585-600) applies the attacker's hit-chance modifier and rolls:

chance = attackerHitMod(baseChance)            # may exceed 100 or go below 0
if chance <= 0  -> miss (no roll)
if chance >= 100 -> hit  (no roll)
else            -> rnd.ProbabilityRoll(chance, 100)
  • Modifiers can push chance out of [0,100]; it is clamped to certain hit / certain miss with no roll spent (test HitMissOutOfBounds, combat_tests.cpp:1061-1097).
  • A hit/miss roll is spent per damaged target when the chance is strictly between 0 and 100, but not when it is a sure hit/miss (DamagingRandomRollsTests, combat_tests.cpp:2218-2277).
  • The hit/miss roll happens even when the eventual damage will be 0 due to a damage modifier (test HitMissRolledForZeroDamage, combat_tests.cpp:2279-2298).
  • No hit/miss roll is made against a target that is already dead this round (test NoRollForAlreadyDead, combat_tests.cpp:2300-2334).

3.3 Applying damage: shield vs armour#

Every fighter has two HP pools (proto::HP, proto/combat.proto:199-217):

  • armour — "permanent" HP. Regenerates slowly (or not at all). When both pools reach 0 the fighter dies.
  • shield — regenerating HP, typically the front-line buffer.
  • mhp — partial regeneration in 1/1000-HP units (milli-HP), tracked per pool.

ComputeDamage (src/combat.cpp:608-655) decides how a single damage number splits across shield and armour, honouring per-attack percentages:

  • shield_percent (default 100) and armour_percent (default 100) scale how effective the attack is against each pool. proto/combat.proto:123-131.
  • Damage hits shield first. The amount available for shield is floor(dmg * shieldPercent / 100); it is capped at remaining shield.
  • If the shield is not fully depleted, no damage carries to armour even if "base damage" remains (src/combat.cpp:633-636). This is important: a low shield_percent weapon can fail to break through a shield at all.
  • If the shield is exhausted, the base damage consumed by the shield is divided back out (baseDoneShield = done.shield * 100 / shieldPercent) and subtracted; the remainder goes to armour using armour_percent the same way.
  • All integer math rounds down, guaranteeing total damage done never exceeds the rolled base damage (src/combat.cpp:624-626).

The exhaustive truth table is the test HpReduction (combat_tests.cpp:1325-1401). Worked examples from that table (format dmg / shield%·armour% : before(sh,ar) -> after(sh,ar)):

dmg shield% armour% before (sh, ar) after (sh, ar) Notes
1 100 100 (1, 10) (0, 10) shield absorbs, armour untouched
2 100 100 (1, 10) (0, 9) 1 to shield, 1 carries to armour
24 200 50 (12, 100) (0, 91) shield-buster: 200% vs shield
24 50 200 (6, 100) (0, 76) armour-buster: 200% vs armour
10 200 50 (100, 100) (80, 100) 200% vs shield; shield not yet exhausted, armour untouched
10 0 100 (100, 100) (100, 100) 0% shield → can't touch shielded target
10 100 0 (5, 100) (0, 100) 0% armour → can't kill through armour
10 30 100 (1, 100) (0, 93) rounding example

Design note: "syphon"/drain weapons use armour_percent: 0 so they only ever drain shields (fitments.pb.text, e.g. lf syphon armour_percent: 0). "0% vs shield" weapons exist conceptually but bullets above show 0% pools simply cannot damage that pool.

received_damage_modifier on the target (proto/combat.proto:253-257) is applied to the rolled damage before shield/armour split (src/combat.cpp:686-696). Because StatModifier only "sticks" once the change is ≥ 1 full point (§5), small attacks are unaffected (test ReceivedDamageModifier, combat_tests.cpp:1143-1167).

3.4 Directed vs area (AoE) attacks#

DealDamage (src/combat.cpp:784-860) iterates the fighter's attacks. For each attack:

  1. Skip if its gain_hp flag differs from the phase being processed (§3.6).
  2. If the attack has its own range, the chosen target must be within mod.range(attack.range()) or the attack is skipped for this round (src/combat.cpp:818-824). This is how a multi-weapon vehicle fires only the weapons that reach (tests OnlyAttacksInRange combat_tests.cpp:874-891, AreaTargetTooFar combat_tests.cpp:957-977).
  3. Roll damage once if the attack has a damage block (src/combat.cpp:826-828).
  4. Geometry (proto/combat.proto:96-111):
range set? area set? Behaviour
yes no Directed: hits only the selected target tile.
yes yes AoE around target: centre = target tile, radius = mod.range(area).
no yes AoE around self: centre = attacker tile, radius = mod.range(area).
no no Invalid for a real attack (would have no target / centre).

AoE attacks call ProcessCombatTargets again at the centre to enumerate everyone in the area (src/combat.cpp:841-849), applying the same single rolled dmg and any effects to each. The friendlies flag inverts whom the AoE hits: !attack.friendlies() selects enemies for normal AoE, friendlies for support AoE (src/combat.cpp:843).

Directed (non-area) attacks require a target and must not be friendly (src/combat.cpp:851-858).

Tests: AreaAroundTarget (combat_tests.cpp:931-955), MixedAttacks (combat_tests.cpp:979-1012).

3.5 Combat effects (non-damage)#

ApplyEffects (src/combat.cpp:757-782) accumulates the attack's CombatEffects (proto/combat.proto:61-84) onto each affected target. The fields:

Effect field Type Meaning
speed StatModifier Movement-speed change (e.g. retarder slows).
range StatModifier Attack range / AoE size change on the victim.
hit_chance StatModifier Change to the victim's own attacks' hit chance.
shield_regen StatModifier Change to the victim's shield regen rate.
mentecon bool Victim treats everyone as both friend and foe (see §4).

Effects from multiple attacks stack additively within a round (src/combat.cpp:772-781; test Effects, combat_tests.cpp:1442-1487 shows two identical attacks doubling the effect, e.g. two -10% speed → -20%).

Effect lifetime is exactly one combat round. At the end of damage processing, ClearAllEffects() wipes every fighter's effects, then the freshly-accumulated effects for this round are written back (src/combat.cpp:1014-1027). So a slow/range-reduction lasts only until the next combat block — you must keep hitting a target to keep it slowed. Test Effects confirms a pre-existing effect is reset if not re-applied (combat_tests.cpp:1444-1459). Test EffectsOkOnKilledCharacter (combat_tests.cpp:1518-1541) confirms applying effects to a fighter that dies this round is harmless.

3.6 HP gain / drain (gain_hp, the syphon)#

gain_hp attacks (proto/combat.proto:146-152) drain HP from the victim and return it to the attacker. The engine processes all gain_hp attacks first, then all normal attacks (src/combat.cpp:908-914, 963-966) so drains aren't wasted by a normal attack that already collapsed the shield.

Rules (DamageProcessor::Process, src/combat.cpp:895-1012; ApplyDamage gain_hp branch src/combat.cpp:733-755):

  • Drained amounts are tracked per (target, attacker) in gainHpDrained.
  • Only shield drain is supported; armour drain triggers a fatal CHECK (src/combat.cpp:935-937). In the live game only the syphon fitment uses this, with armour_percent: 0.
  • HP gains are credited after all damage and kills are resolved (src/combat.cpp:990-1012). HP gained this round does not prevent the attacker from dying this round (test DoesNotPreventDeath, combat_tests.cpp:1650-1666), and does not stop the victim from dying either.
  • Gains are capped at the attacker's max_hp (test CappedAtMax, combat_tests.cpp:1598-1621).
  • Multiple drainers on one target: if the target ends with HP left, everyone gets what they drained. If the target is fully drained to 0 in that pool and there was more than one drainer, nobody gets the HP from that exhausted pool — to keep the result independent of processing order (src/combat.cpp:926-948; tests MultipleAttackers combat_tests.cpp:1700-1734 and MultipleAttackersCompleteDrain combat_tests.cpp:1736-1769).
  • A dead attacker is not credited gained HP (src/combat.cpp:991-999).

3.7 Self-destruct#

SelfDestruct (proto/combat.proto:162-175) is AoE damage emitted when a fighter dies. ProcessSelfDestructs (src/combat.cpp:862-893):

  • Runs for every newly-dead fighter, after the normal damage phase (src/combat.cpp:968-988).
  • The dead fighter is at 0 HP, so all its low-HP boosts apply to the self-destruct damage/range (src/combat.cpp:869-874; test StackingAndLowHpBoost, combat_tests.cpp:1898-1923, where a one-shot kill still gets the low-HP boost).
  • Self-destruct uses the fighter's original effects (range modifier etc.), recomputed fresh, so an in-round range debuff does not change it (test CombatEffects, combat_tests.cpp:1961-1988).
  • Damage is rolled once per self-destruct entry and applied to everyone in mod.range(sd.area()) (src/combat.cpp:876-892).
  • Chain reactions: self-destructs can kill more fighters, which then self-destruct, processed in repeated rounds until no new deaths (src/combat.cpp:972-988). Test Chain (combat_tests.cpp:2020-2067) runs a 100-long chain. Returned dead set is sorted by TargetKey, not kill time.
  • Already-dead targets are skipped without a hit roll (src/combat.cpp:666-675).
  • Hit/miss uses self-destruct weapon_size vs target size like any attack (test HitMissChance, combat_tests.cpp:1925-1959).
  • Self-destruct respects safe zones — it cannot hit a fighter in a no-combat zone (test SafeZone, combat_tests.cpp:2069-2101).

3.8 Order-independence guarantees#

Because the GSP must be reproducible regardless of internal iteration order, several quantities are snapshotted up front:

  • Combat modifiers for all attackers are computed before any damage is dealt (modifiers map, src/combat.cpp:896-904). So low-HP boosts use HP as it was at the start of the round (test BasedOnOriginalHp, combat_tests.cpp:1827-1856). Mutual attackers both reach low-HP next round, not this one.
  • The range modifier used for range/area is the start-of-round value even if another attack in the same round applies a range debuff (test ModifiedRange, combat_tests.cpp:1169-1205).
  • Effects are accumulated into newEffects and swapped in only at the end (src/combat.cpp:1014-1027).

4. Faction rules & mentecon#

  • Each fighter has a faction: Red (r), Green (g), Blue (b), or Ancient (neutral). database/faction.hpp, used via f.GetFaction().
  • Enemies = different faction. Friendlies = same faction. The target finder is told which set to enumerate (src/combat.cpp:148-152).
  • Ancient buildings belong to no player faction and are valid enemy targets for all three player factions (test WithBuildings, combat_tests.cpp:428-465; an ancient building at the same tile is targeted by a Red character).
  • Friendly attacks (friendlies: true) target same-faction fighters — used for buffs like the ally shield projector (src/combat.cpp:843, proto proto/combat.proto:154-158).

Mentecon (CombatEffects.mentecon, proto/combat.proto:76-82) overrides faction logic on the affected fighter: it looks for both enemies and friendlies for both normal and friendly attacks, and AoE hits everyone of either type (src/combat.cpp:148-150). It is applied as an effect by the vhf mentecon ("Targeting System Disruptor") weapon, which is a directed range: 4 attack (it afflicts one chosen target, not an area) and is faction-locked to Blue b. Tests TargetSelectionTests.Mentecon (combat_tests.cpp:389-426), DealDamageTests.Mentecon (combat_tests.cpp:1248-1285), SelfDestructTests.Mentecon (combat_tests.cpp:1990-2018). Note a mentecon'd fighter will damage and slow even its own faction (its allies become collateral).


5. Stat modifiers (StatModifier)#

All boosts/debuffs use StatModifier (proto/modifier.proto:28-42, logic in src/modifier.hpp):

result = base + (base * percent) / 100 + absolute        # integer math

Properties (src/modifier.hpp:78-93):

  • percent and absolute are additive, not multiplicative; stacking modifiers adds their percent and absolute fields (operator+=, src/modifier.hpp:64-72).
  • The relative part multiplies the original base only, not the absolute part.
  • Integer flooring means a change is only applied once it amounts to ≥ 1 whole point: -10% of a base of 5 stays at 5 (the comment at src/modifier.hpp:79-86 calls this "sticking" to the current value). This is why many single-point attacks ignore small received_damage reductions.

ComputeModifier (src/combat.cpp:82-107) builds a fighter's effective combat modifier each time it is needed, from three sources:

  1. Low-HP boosts (LowHpBoost, proto/combat.proto:177-193): each boost activates when 100 * currentArmour <= max_hp_percent * maxArmour, i.e. armour HP at or below the threshold percent (src/combat.cpp:93-101). Active boosts add to damage and range. Threshold is on armour only. Multiple boosts stack (test Stacking, combat_tests.cpp:1803-1825: three active +100% boosts → 4× damage/range; one with too high HP requirement excluded).
  2. Effects in force on the fighter (range, hit_chance) from last round (src/combat.cpp:103-106).
  3. hit_chance_modifier baked into the fighter's combat data (fitments) (src/combat.cpp:105).

The three combat-relevant modifiers tracked are damage, range, hitChance (CombatModifier, src/combat.cpp:58-76).


6. Safe zones & no-combat zones#

Config: proto/roconfig/safezones.pb.text (mainnet). Each safe_zone has a centre, a radius (filled as a solid L1 disk, mapdata/safezones.cpp:60-73), and an optional faction. Loaded into a per-tile lookup by SafeZones (mapdata/safezones.hpp, mapdata/safezones.cpp).

Two kinds (mapdata/safezones.tpp):

Kind faction set? IsNoCombat? StarterFor
Faction starter zone yes (r/g/b) true returns that faction
Neutral / partner zone no true INVALID

IsNoCombat returns true for all of them (mapdata/safezones.tpp, the IsNoCombat switch). StarterFor distinguishes faction starter zones from neutral ones (used by spawning/respawn, not by combat). Overlapping safe zones are forbidden at load (mapdata/safezones.cpp:63-66).

What a no-combat zone blocks#

For any tile where IsNoCombat(c) is true:

  • A fighter standing there is never selected as a target (src/combat.cpp:156-162).
  • A fighter standing there is not given a target and never firesSelectTarget early-returns with no enemy and no friendly target (src/combat.cpp:357-365).
  • It cannot be damaged or have effects applied: ApplyDamage and ApplyEffects both CHECK the target is not in a no-combat zone (src/combat.cpp:664, :761); self-destruct AoE also skips it (src/combat.cpp:884-892).

A fighter just outside the zone can be hit normally even if a closer fighter is inside — the safe-zone fighter is simply filtered out and the next-closest valid target is chosen (test TargetSelectionTests.SafeZone, combat_tests.cpp:494-538; damage version DealDamageTests.SafeZone, combat_tests.cpp:1207-1246). Regen still happens inside safe zones (regen does not consult safe zones).

Mainnet safe-zone catalogue#

From proto/roconfig/safezones.pb.text:

Purpose Centre (x, y) Radius Faction
Red starter zone (1960, -2601) 300 r
Green starter zone (-3472, 1824) 300 g
Blue starter zone (547, 2497) 300 b
Neutral / partner buildings 23 zones, each radius 30 30 none

The 23 neutral zones surround the neutral/partner buildings; their centres are listed in safezones.pb.text:26-148 (e.g. (-125, 810), (-1301, 902), (3479, -184), …). Counts verified directly from the config: 3 starter zones (radius 300) + 23 neutral zones (radius 30) = 26 safe_zones entries total.

Testnet / regtest differences#

proto/roconfig/test_safezones.pb.text replaces the above with a small fixed set for testing, e.g. a neutral zone at (2042, 0) radius 10, a Red zone at (-2042, 100) radius 10, etc. The GSP tests use the tile (2042, 10) as a known safe tile (combat_tests.cpp:59-90). Mechanics are identical; only the geometry differs by chain.


7. Death & loot#

ProcessKills (src/combat.cpp:1281-1304) dispatches each dead TargetId to ProcessCharacter or ProcessBuilding.

7.1 Character (vehicle) death#

ProcessCharacter (src/combat.cpp:1109-1162):

  1. Cancel prospecting: if the dead character was prospecting a region, the region's prospecting_character is cleared so it can be prospected again (src/combat.cpp:1117-1134; test CancelsProspection, combat_tests.cpp:2565-2595).
  2. Drop loot on the ground at the character's position (src/combat.cpp:1136-1158):
    • The entire inventory is always dropped.
    • Each equipped fitment has a EQUIPPED_FITMENT_DROP_PERCENT = 20% independent chance to survive and be dropped as loot (src/combat.cpp:53, :1141-1143). Rolls are independent per fitment, not a single shared roll (test MaybeDropsFitments, combat_tests.cpp:2618-2666).
    • The vehicle itself is always destroyed — it never drops (src/combat.cpp:1138; test asserts basetank count 0, combat_tests.cpp:2649-2650).
  3. Remove the vehicle from the dynamic-obstacle map (src/combat.cpp:1160; test UpdatesDynObstacles, combat_tests.cpp:2514-2529).
  4. Delete the character: remove it from all damage lists, delete its ongoing operations, delete the DB row (DeleteCharacter, src/combat.cpp:1073-1081). Tests RemovesFromDamageLists (combat_tests.cpp:2531-2546), RemovesOngoings (combat_tests.cpp:2548-2563).

Loot is added to the GroundLootTable tile at the death position (test DropsInventory, combat_tests.cpp:2597-2616).

7.2 Building destruction#

ProcessBuilding (src/combat.cpp:1164-1277) is more involved. Everything inside the building is gathered into one combined inventory, then partially dropped:

  • Account inventories stored in the building → added to combined inventory (src/combat.cpp:1179-1183).
  • Characters inside the building are all destroyed; their inventory, their vehicle item, and all their fitments are added to the combined inventory (src/combat.cpp:1185-1200). (Inside a building, vehicle and fitments go to the pool — unlike on-map death where the vehicle is destroyed.)
  • Ongoing operations inside (src/combat.cpp:1202-1224):
    • A blueprint copy in progress → its original_type (BPO) is returned to the pool; copies in progress (BPC) are lost (test MayDropCopiedBlueprint, combat_tests.cpp:2905-2941).
    • An item construction in progress → its original_type (BPO) returned to pool if present (test MayDropBlueprintsFromConstruction, combat_tests.cpp:2943-2990).
  • DEX orders (src/combat.cpp:1226-1236): coins reserved by open bids are refunded to their owners' accounts (test RefundsBidCubits, combat_tests.cpp:2791-2809); items reserved by open asks are added to the pool (test MayDropOrderItems, combat_tests.cpp:2992-3023). Orders are then deleted.
  • Construction inventory of a foundation under construction → added to the pool (src/combat.cpp:1240-1241; test MayDropConstructionInventory, combat_tests.cpp:2875-2903).

Then the building is removed from the obstacle map (src/combat.cpp:1243) and each inventory line drops with BUILDING_INVENTORY_DROP_PERCENT = 30% chance (src/combat.cpp:47, :1255-1271):

  • When an item line drops, it drops in full quantity, otherwise nothing of that line (test MayDropAnyInventoryItem, combat_tests.cpp:2811-2873; chance verified in ItemDropChance, combat_tests.cpp:3025-3050).
  • The drop rolls are done in sorted item-name order for determinism (src/combat.cpp:1245-1255; test OrderOfItemRolls, combat_tests.cpp:3052-3118, which replays rolls over raw araw i).

Finally the building's inventories, ongoings, and orders are deleted and the building row removed (src/combat.cpp:1273-1276). All dropped loot lands at the building's centre tile (src/combat.cpp:1252).

Constant Value Where Applies to
EQUIPPED_FITMENT_DROP_PERCENT 20% src/combat.cpp:53 each fitment on a killed on-map vehicle
BUILDING_INVENTORY_DROP_PERCENT 30% src/combat.cpp:47 each item line in a destroyed building

8. Fame (kill rewards)#

After a kill, FameUpdater::UpdateForKill (src/fame.cpp:62-127) transfers fame. Only character kills give fame (src/fame.cpp:129-137); building destruction does not.

Constants (src/fame.cpp:34-38): MAX_FAME = 9999, FAME_PER_KILL = 100.

Rules:

  • Killers are everyone on the victim's damage list (DamageLists), grouped by owning account (src/fame.cpp:79-87). The damage list records who damaged a character within the last damage_list_blocks = 100 blocks (proto/roconfig/params.pb.text:8; entries added in ApplyDamage, src/combat.cpp:706-709, only for character-vs-character damage).
  • Kill counter: every distinct killer account's kills is incremented (src/fame.cpp:96-99), regardless of fame level.
  • Fame level = min(fame / 1000, 8) (src/fame.cpp:56-59).
  • A killer only receives fame if their level is within ±1 of the victim's level (src/fame.cpp:101-108).
  • Fame lost by the victim = min(victimFame, 100) (src/fame.cpp:118). It is split evenly across all distinct killer accounts (fameLost / owners.size(), src/fame.cpp:120-124) — note the divisor is the count of all killers, even those out of level range (so out-of-range killers can "dilute" the reward).
  • Each in-range killer gains famePerKiller; the victim loses the full fameLost (src/fame.cpp:120-126). Fame is clamped to [0, 9999] on write (src/fame.cpp:50-52).

Damage-list mechanics relevant to combat: an existing entry persists when a new attacker is added (test DamageListTests.Basic, combat_tests.cpp:2340-2366); reciprocal kills add each to the other's list (ReciprocalKill, combat_tests.cpp:2368-2391); multiple killers are all tracked even if the target was already dead from an earlier attacker (MultipleKillers, combat_tests.cpp:2393-2420); self-destruct damage is credited to the destructed character, but a self-destruct chain does not credit an already-dead intermediate to the next link (WithSelfDestruct, combat_tests.cpp:2422-2456).


9. HP regeneration (RegenerateHP)#

RegenerateHP (src/combat.cpp:1371-1382) runs for every fighter (characters and buildings, including those inside buildings and in safe zones — it does not check safe zones) via fighters.ProcessForRegen.

RegenerateFighterHP (src/combat.cpp:1340-1367) regenerates armour and shield independently using RegenerateHpType (src/combat.cpp:1316-1335):

newMilli = oldMilli + mhpRate          # mhpRate is "milli-HP per block"
newCur   = oldCur + newMilli / 1000
newMilli = newMilli % 1000
if newCur >= max: newCur = max; newMilli = 0   # clamp, drop leftover milli
  • Regen rates are stored in RegenData.regeneration_mhp in milli-HP per block (proto/combat.proto:224-233). Example: 1000 mhp/block = 1 HP/block.
  • Partial (milli) HP is tracked between blocks and only converts to whole HP when it crosses 1000 (test RegenerateHpTests.Works, combat_tests.cpp:3124-3177).
  • Shield regen rate is modified by the shield_regen effect in force on the fighter (src/combat.cpp:1356-1358; test RateModifierEffect, combat_tests.cpp:3212-3227: 10000 mhp/block at +50% → 15 HP instead of 10). Armour regen is not effect-modified.
  • Buildings regenerate too (test BuildingsRegenerate, combat_tests.cpp:3179-3193).
  • Vehicles inside buildings still regenerate (test InsideBuilding, combat_tests.cpp:3195-3210).
  • Death uses whole HP only: a fighter dies when whole armour + whole shield reach 0, even if it had 999/1000 partial HP in both pools (src/combat.cpp:719-728). So you cannot survive a killing blow on partial regen.

Live armour regen is supplied by lf/mf/hf/vhf armourregen ("Armour Repair"), armour_regen { absolute: 2000..14000 } (mhp/block). Shield regen scales with the "Shield Replenisher" (replenisher, self shield_regen +15%), and the "Shield Projector" (allyreplenish, a friendly AoE applying shield_regen +15% effect). Active-repair-while-docked is handled separately by the building repair service (armour_repair_hp_per_block, params.pb.text:11), not by this passive regen path.


10. Weapon & combat-fitment catalogue#

Vehicle attacks are not stored on the vehicle by the designer directly — they are derived from fitted items and cached in the vehicle's combat_data (proto CombatData, proto/combat.proto:241-274; the derivation from fitments lives in src/fitments.cpp and is documented in the fitments/vehicles doc). Below is the combat semantics of each real fitment type, taken from proto/roconfig/items/fitments.pb.text, with display names from the game UI. Each comes in four tiers: Light lf, Medium mf, Heavy hf, Very Heavy vhf. Values shown are the Light → Very Heavy progression.

10.1 Direct-damage weapons (directed, range: 5)#

beam and gun share the same range/damage/weapon_size curve but differ in their damage-type split (§3.3): the Tachyon Beam is anti-shield (shield_percent: 140, armour_percent: 60) while the Rail Gun is anti-armour (shield_percent: 60, armour_percent: 140). They are not interchangeable — pick the beam against shielded targets and the gun against armour-tanks.

Code base Display name (lf/mf/hf/vhf) Geometry Damage min–max weapon_size shield% / armour%
beam Light…Very Heavy Tachyon Beam range 5 5–15 → 25–200 30 → 100 140 / 60 (anti-shield)
gun Light…Very Heavy Rail Gun range 5 5–15 → 25–200 30 → 100 60 / 140 (anti-armour)

10.2 Area-damage weapons#

Code base Display name Geometry Damage min–max weapon_size
bomb Energy Bomb self-AoE area: 3 3–9 → 25–75 30 → 100
laser Plasma Cannon (faction-locked Red r) AoE-around-target range: 5 area: 3 3–7 → 30–60 30 → 100
missile Missile Launcher AoE-around-target range: 6 area: 2 1–5 → 20–40 30 → 100

10.3 Syphon / drain (gain_hp: true, armour_percent: 0)#

Code base Display name Geometry Damage min–max (shield only) weapon_size
syphon Syphon range 5, drain 3–9 → 25–75 30 → 100
syphonaoe Syphon Field self-AoE area: 4, drain 1–5 → 20–40 30 → 100

Drained shield HP is returned to the attacker (capped at max), per §3.6.

10.4 Self-destruct (self_destruct, area: 5)#

Code base Display name AoE Damage min–max weapon_size
selfdestruct Vostock (faction-locked Red r) 5 30–50 → 1500–2500 30 → 100

10.5 Debuff / control weapons (effect attacks)#

Code base Display name Geometry Effect
retarder Temporal Disrupter self-AoE area: 3 speed −15% → −30%
longretard Wide Temporal Disrupter AoE-around-target range: 4 area: 2 speed −10% → −25%
rangered Spatial Blur self-AoE area: 3 range −15% (all tiers)
hitred Tracking Disrupter (faction-locked Blue b) self-AoE area: 3 hit_chance −2% → −8% (on victim's attacks)
mentecon (vhf only) Targeting System Disruptor (faction-locked Blue b) directed range: 4 (no area) sets mentecon on the single targeted victim

10.6 Support (friendly) weapons#

Code base Display name Geometry Effect
allyreplenish Shield Projector friendlies: true area: 1 shield_regen +15% to allies

10.7 Passive combat fitments (modify own stats, no attack)#

Code base Display name Modifier
shield Shield Enhancer max_shield +10%
replenisher Shield Replenisher shield_regen +15% (self)
armourregen Armour Repair armour_regen +2000 → +14000 mhp/block (absolute)
plating Tritanian Plating max_armour +10% (faction-locked Green)
dmgred Armour Hardener received_damage −5%
dmgext Target Analyser damage +5% (self attacks)
rangeext Target Enhancer range +10% (self attacks)
hitext Tracking Enhancer hit_chance +10% → +8% (self attacks)
lowhpboost Zerkozis low_hp_boost: at ≤10% armour, damage +20%, range +20%
turbo Energy Enhancement speed +10% (mobility, not combat)
multiplier Qubit Multiplier complexity +20% (fitting capacity, not combat)

Notes:

  • dmgred (Armour Hardener) feeds the target's received_damage_modifier; because of StatModifier flooring, a −5% reduction only bites on damage ≥ 20.
  • lowhpboost (Zerkozis) feeds low_hp_boosts; it triggers on armour ≤ 10% of max and boosts the next round's damage and range (and self-destruct, since the dying fighter is at 0 armour). Faction-locked to Green in the live config.
  • Real weapons use weapon_size 30/40/60/100 for Light/Medium/Heavy/Very Heavy; small vehicles set a small target_size to dodge heavy weapons (§3.2).

10.8 Building combat (turrets)#

Buildings can also be fighters. Example: the Red turret (r rt, proto/roconfig/buildings/r_rt.pb.text):

  • Foundation stage: empty combat_data {} (no attacks), max_hp { armour: 100, shield: 100 }, shield regen 10000 mhp/block.
  • Full building stage: one AoE-around-target attack range: 6 area: 2, damage 3–9, weapon_size: 40; max_hp { armour: 1000, shield: 1000 }, shield regen 10000 mhp/block.

Green and Blue turrets (g rt, b rt) share the same combat profile.


11. Worked move/state examples#

Combat takes no player moves — there is no attack RPC. All examples are state shapes, reconstructed from the protos and the test fixtures. JSON keys below follow the proto field names (proto/combat.proto); the client reads these from full-state.

11.1 A vehicle's combat data (cached on the character)#

// proto::CombatData, derived from fitments and cached on the vehicle.
// E.g. this might be a Red "rv st" (Raider) with the following fitted:
{
  "attacks": [
    { "range": 5, "damage": { "min": 5, "max": 15, "shield_percent": 140, "armour_percent": 60, "weapon_size": 30 } }, // lf beam (Light Tachyon Beam, anti-shield)
    { "range": 5, "area": 3, "damage": { "min": 3, "max": 7, "weapon_size": 30 } }, // lf laser (Light Plasma Cannon, AoE around target)
    { "area": 3, "effects": { "speed": { "percent": -15 } } }                     // lf retarder (Light Temporal Disrupter, self-AoE slow)
  ],
  "low_hp_boosts": [
    { "max_hp_percent": 10, "damage": { "percent": 20 }, "range": { "percent": 20 } } // lf lowhpboost (Light Zerkozis)
  ],
  "self_destructs": [
    { "area": 5, "damage": { "min": 30, "max": 50, "weapon_size": 30 } }          // lf selfdestruct (Light Vostock)
  ],
  "received_damage_modifier": { "percent": -5 },   // lf dmgred (Light Armour Hardener)
  "hit_chance_modifier": { "percent": 10 },        // lf hitext (Light Tracking Enhancer)
  "target_size": 5                                  // small vehicle -> dodges big weapons
}

11.2 Current target and HP on a fighter#

{
  "target": { "type": 2, "id": 1234 },   // TYPE_CHARACTER 1234; absent "id" => no target
  "friendlytargets": true,                // a friendly is in range of a friendly attack
  "hp":  { "armour": 873, "shield": 412, "mhp": { "armour": 250, "shield": 0 } },
  "maxhp": { "armour": 1000, "shield": 500 },   // from RegenData.max_hp
  "effects": { "speed": { "percent": -15 } }     // currently slowed (clears next combat round)
}

11.3 A drain (syphon) attack proto#

{ "range": 5, "damage": { "min": 3, "max": 9, "armour_percent": 0, "weapon_size": 30 }, "gain_hp": true }

11.4 An ally-buff (friendly) attack proto#

{ "area": 1, "friendlies": true, "effects": { "shield_regen": { "percent": 15 } } }

Open questions#

  1. Exact derivation of target_size / weapon_size from vehicle/fitment data. The combat-relevant effects of these fields are fully specified in combat.cpp, but the mapping from VehicleSize (config.proto:35, :136, :161) to numeric target_size/weapon_size is done in src/fitments.cpp (not in scope for this combat doc). Designers should consult the fitments/vehicles doc for the concrete numbers per vehicle class. combat.cpp only consumes the already-derived integers.
  2. Whether the fame-split divisor counting all killers (incl. out-of-range) is intended. src/fame.cpp:120-124 divides fameLost by owners.size() (all distinct killer accounts) but only credits in-range killers. This means out-of-range participants reduce the per-killer reward without receiving any. The code is unambiguous; whether it is the intended design is a balance question for designers.