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 underproto/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:
mvProc.ProcessAdmin/mvProc.ProcessAll— apply player moves (incl. fitting, spawning, entering/leaving requests).src/logic.cpp:103-104fame.GetDamageLists().RemoveOld(damage_list_blocks)— expire stale damage-list entries.src/logic.cpp:106-107AllHpUpdates(...)— the whole combat resolution (see below).src/logic.cpp:109ProcessAllOngoings— construction, prospecting, blueprint copies, etc.:110ProcessAllMining:112ProcessAllMovement:113ProcessEnterBuildings— 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-119FindCombatTargets(...)— pick targets for next block.src/logic.cpp:121
AllHpUpdates (src/combat.cpp:1386-1399) runs three coupled steps in order:
DealCombatDamage→ returns the set of dead fighters (src/combat.cpp:1390).- For each dead character,
fame.UpdateForKill(fame transfer).src/combat.cpp:1392-1393 ProcessKills→ delete dead entities, drop loot, refund, etc.src/combat.cpp:1395-1396RegenerateHP→ 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:152andSelectNormalTarget(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 fromf.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 testTargetSelectionTests.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):
- Attack range = the fighter's longest enemy attack range (
GetAttackRange(false)), which is the max over all non-friendly attacks ofrange(orareaif the attack has no range) —database/combat.cpp:142-165. If the fighter has no enemy attacks the value isNO_ATTACKS(-1) and no target is selected (src/combat.cpp:281-285). - The range is modified by the fighter's range modifiers (
mod.range, §5):range = mod.range(range)(src/combat.cpp:287). - Among all valid enemies in range, keep only those at the minimum L1 distance
(
src/combat.cpp:291-309). - 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 (testRandomisation,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 noteproto/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) andOnlyAreaAttacks(combat_tests.cpp:565-587). - If a fighter only has area attacks (no
range),areais 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 (testHitMissOutOfBounds,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) andarmour_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 lowshield_percentweapon 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 usingarmour_percentthe 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: 0so they only ever drain shields (fitments.pb.text, e.g.lf syphonarmour_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:
- Skip if its
gain_hpflag differs from the phase being processed (§3.6). - If the attack has its own
range, the chosen target must be withinmod.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 (testsOnlyAttacksInRangecombat_tests.cpp:874-891,AreaTargetTooFarcombat_tests.cpp:957-977). - Roll damage once if the attack has a
damageblock (src/combat.cpp:826-828). - 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, witharmour_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 (testDoesNotPreventDeath,combat_tests.cpp:1650-1666), and does not stop the victim from dying either. - Gains are capped at the attacker's
max_hp(testCappedAtMax,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; testsMultipleAttackerscombat_tests.cpp:1700-1734andMultipleAttackersCompleteDraincombat_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; testStackingAndLowHpBoost,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). TestChain(combat_tests.cpp:2020-2067) runs a 100-long chain. Returned dead set is sorted byTargetKey, not kill time. - Already-dead targets are skipped without a hit roll (
src/combat.cpp:666-675). - Hit/miss uses self-destruct
weapon_sizevs target size like any attack (testHitMissChance,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
(
modifiersmap,src/combat.cpp:896-904). So low-HP boosts use HP as it was at the start of the round (testBasedOnOriginalHp,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
newEffectsand 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 viaf.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, protoproto/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):
percentandabsoluteare additive, not multiplicative; stacking modifiers adds theirpercentandabsolutefields (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 atsrc/modifier.hpp:79-86calls this "sticking" to the current value). This is why many single-point attacks ignore smallreceived_damagereductions.
ComputeModifier (src/combat.cpp:82-107) builds a fighter's effective combat modifier
each time it is needed, from three sources:
- Low-HP boosts (
LowHpBoost,proto/combat.proto:177-193): each boost activates when100 * currentArmour <= max_hp_percent * maxArmour, i.e. armour HP at or below the threshold percent (src/combat.cpp:93-101). Active boosts add todamageandrange. Threshold is on armour only. Multiple boosts stack (testStacking,combat_tests.cpp:1803-1825: three active +100% boosts → 4× damage/range; one with too high HP requirement excluded). - Effects in force on the fighter (range, hit_chance) from last round
(
src/combat.cpp:103-106). hit_chance_modifierbaked 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 fires —
SelectTargetearly-returns with no enemy and no friendly target (src/combat.cpp:357-365). - It cannot be damaged or have effects applied:
ApplyDamageandApplyEffectsbothCHECKthe 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):
- Cancel prospecting: if the dead character was prospecting a region, the region's
prospecting_characteris cleared so it can be prospected again (src/combat.cpp:1117-1134; testCancelsProspection,combat_tests.cpp:2565-2595). - 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 (testMaybeDropsFitments,combat_tests.cpp:2618-2666). - The vehicle itself is always destroyed — it never drops (
src/combat.cpp:1138; test assertsbasetankcount 0,combat_tests.cpp:2649-2650).
- Remove the vehicle from the dynamic-obstacle map (
src/combat.cpp:1160; testUpdatesDynObstacles,combat_tests.cpp:2514-2529). - Delete the character: remove it from all damage lists, delete its ongoing operations,
delete the DB row (
DeleteCharacter,src/combat.cpp:1073-1081). TestsRemovesFromDamageLists(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 (testMayDropCopiedBlueprint,combat_tests.cpp:2905-2941). - An item construction in progress → its
original_type(BPO) returned to pool if present (testMayDropBlueprintsFromConstruction,combat_tests.cpp:2943-2990).
- A blueprint copy in progress → its
- DEX orders (
src/combat.cpp:1226-1236): coins reserved by open bids are refunded to their owners' accounts (testRefundsBidCubits,combat_tests.cpp:2791-2809); items reserved by open asks are added to the pool (testMayDropOrderItems,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; testMayDropConstructionInventory,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 inItemDropChance,combat_tests.cpp:3025-3050). - The drop rolls are done in sorted item-name order for determinism
(
src/combat.cpp:1245-1255; testOrderOfItemRolls,combat_tests.cpp:3052-3118, which replays rolls overraw a…raw 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 lastdamage_list_blocks = 100blocks (proto/roconfig/params.pb.text:8; entries added inApplyDamage,src/combat.cpp:706-709, only for character-vs-character damage). - Kill counter: every distinct killer account's
killsis 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 fullfameLost(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_mhpin 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_regeneffect in force on the fighter (src/combat.cpp:1356-1358; testRateModifierEffect,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, selfshield_regen +15%), and the "Shield Projector" (allyreplenish, a friendly AoE applyingshield_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'sreceived_damage_modifier; because ofStatModifierflooring, a −5% reduction only bites on damage ≥ 20.lowhpboost(Zerkozis) feedslow_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_size30/40/60/100 for Light/Medium/Heavy/Very Heavy; small vehicles set a smalltarget_sizeto 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#
- Exact derivation of
target_size/weapon_sizefrom vehicle/fitment data. The combat-relevant effects of these fields are fully specified incombat.cpp, but the mapping fromVehicleSize(config.proto:35,:136,:161) to numerictarget_size/weapon_sizeis done insrc/fitments.cpp(not in scope for this combat doc). Designers should consult the fitments/vehicles doc for the concrete numbers per vehicle class.combat.cpponly consumes the already-derived integers. - Whether the fame-split divisor counting all killers (incl. out-of-range) is
intended.
src/fame.cpp:120-124dividesfameLostbyowners.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.