The server keeps each life's health; the victim confirms the hit
Status: Accepted (2026-11-04)
Context
Every client computes the damage to its own player, and the server has no collision world: only the victim's client sees a wall between the shooter and itself, and only the clients' physics know the point and force of an explosion, a fire, a ram or a fall. If the victim's client were also the authority of its health and death:
- a client that keeps its own health up (a ready-made "infinite health" trainer) stays alive for everyone until the gamemode notices and kicks it;
- a client that takes 15 % less damage passes under the
god_modesuspicion detector, whose threshold is a felt loss below 10 % of the claimed one; - a gamemode's health command races with damage already in flight. A fixed time window around each command does not hold that race: in a measurement at 200 ms round-trip time, 19 of 80
set_healthcommands crossed a damage report created before the client applied them.
This ADR builds on the events and attribution of ADR 0029, the causes of ADR 0033 and ADR 0034, friendly fire of ADR 0039 and the shot rewind of ADR 0041. Deaths and respawns travel as reliable lifecycle events (ADR 0017); additive health rewards are those of ADR 0014. The wire rules are in Health accounting and death.
Decision
A hybrid. The server owns the amount of health and armor and the death; the victim confirms that a hit happened; damage from the environment stays the victim's observation.
The ledger
The server's health and armor of each player's life (src/vitals.rs) are canonical: Lua reads them, the relay sends them to others as the player's health, and the detectors judge by them. A life starts at 100 health and 0 armor from the spawn or respawn grant. Only the server's commands raise the ledger (spawn, set, add). The owner's frames and accepted damage reports only lower it. A frame value above the ledger is cut and raises the god_mode flag (up to 1.0 above is tolerated: the game rounds health), except for a frame taken before a report the server credited less than 1 s ago: an honest frame showing the health from before the hit still arrives after the report.
Gamemode health commands
player:set_health(value)/set_armor(value)set an absolute value (SetHealth/SetArmor). Lowering is allowed: that is the meaning of "set". The server validatesvalueas finite and within0..=200(MAX_VITAL_SET), puts it into the ledger when sending, and the client clamps to the same range.set_health(0)on a living player kills it through the client's normal death path; if noDeathReportfollows, the server commits the death itself (below).player:add_health(amount, max)/add_armor(amount, max)add to the current value up tomaxand never lower it (GiveHealth/GiveArmor);amountandmaxmust be finite, above 0 and at most 200. The add keeps "never lower", the set does not: an add is a reward and must not erase fresh damage in a race, a set is the gamemode's explicit intent to make the value exactly so. The two intents stay two verbs.- The commands are available only in a life the server counts: after the first frame of the life, while the player is alive and not counted dead. Otherwise they are refused (
PlayerNotAlive). A client drops a set or an add that arrives while its player is dead, and still takes its revision. A set is a point in time: applied after a respawn it would be wrong, and respawning stays the server's decision. - Sets, adds and weapon commands share one reliable lane and the client applies them in one ordered queue, so their order holds:
set_health(50)followed byadd_health(25, 100)gives 75.
Health revision
Each health or armor command carries the connection's next revision (rev). The client echoes the revision of the last command it took in every frame and every damage report (vital_rev). A frame or a report that echoes an older revision than the last command sent does not touch the ledger (a report still gives attribution) until that command is settled: echoed, 2 s after the client acknowledged its event, or 15 s after it was sent. Without the settling, a client that keeps echoing an old revision would switch the ledger off. An echo newer than anything sent is never taken: a frame ignores it, a report with it is discarded.
The client's order
A frame carries the number of the last damage report its client created (damage_seq). The server applies frames and reports to the ledger in the client's order:
- A frame whose
damage_seqis newer than the last credited report waits for that report up to 1 s (a newer frame does not restart the wait), then counts on its own as an observation without attribution. One loss is never deducted twice. - Only a credited report releases a waiting frame: otherwise one made-up
damage_seqwould release all frames and hide every raise. - A credited report sent together with its hit (a bullet, a blow, an explosion or a ram by a player) deducts the loss it claims from what the ledger held before it, armor first as far as the ledger has armor, and the ledger also falls to at most the vitals the report claims were left. Deducting the claimed loss instead of setting the ledger to the report's "after" matters: a client that restores its own health between hits would otherwise send "100 → 54" every time and never lose anything.
- A sum of fire, spray or environmental damage comes after the frames that already showed it, and lowers the ledger only to the vitals it claims were left.
- A report numbered no newer than the
damage_seqof a counted frame does not touch the ledger: the frame already showed its loss. It still gives attribution and pays its floor. - The first frame of a new life does not touch the ledger, and reports numbered before its
damage_seq(at most 64 past the last accepted one) belong to the previous life and are discarded.
Damage floor
An accepted bullet (WeaponShot, VehicleShot) or melee report deducts at least a floor from the ledger, armor first. The "before" of a hit is the lower of the report's claim and the ledger at that point of the order. The floor makes a client that shrinks the damage it takes pay at least the weapon's minimum; it must never be above the game's own damage, or it would correct an honest player.
- The body part does not change the damage to a player, so there is one floor per weapon, and the claimed damage does not affect it.
- A bullet: 0.33 × the weapon's
damagefrom theWeaponPropertiesrevision the shot echoed, at the shooter's skill tier; within 5 s of a skill change, the lower of the two tiers. Vehicle guns fire at the standard tier; the Predator's guns have a fixed damage (floor 0.33 × 15). A shotgun's floor is one pellet. - A melee blow: any melee weapon falls back to the standard combo until its own animations are loaded, and the force of a blow depends on the victim client's stats, so one floor covers every blow: the weakest move at zero stats (1.32).
- 0.01 is subtracted from every floor: the loss a client computes in its own floats can be below the game's by rounding.
- Fire, spray, explosion, ramming and environmental reports have no floor: their amount needs the physics only the clients have.
- A report that arrives after a frame was counted without it (the frame waited out
REPORT_WAIT) still pays its floor. It draws first on what was deducted by the frame that passed exactly itsdamage_seq, the rest comes from the ledger. A frame's loss is known only as a whole and a report's own claim proves nothing, so a frame pays only for the first report after its start; the rest of its loss is spent. A loss deducted before the last health command pays for no floor. Otherwise the loss of one hit (unsent, with a small floor, or from before a heal) could cover another hit that was understated. The price: honest reports late by more thanREPORT_WAIT, several in one frame, or late past a command's grace pay their floor once more.
Damage multipliers
The gamemode sets a damage multiplier per class (dlmp.set_damage_multiplier, DamageMultipliers) for damage that WeaponProperties does not cover: melee, fire, spray, extinguisher, explosion and the Predator's guns.
- The victim's client applies it before the damage: there are no corrections of a living player (below), so the damage the victim shows is the damage the ledger deducts.
- A multiplier, not an absolute damage: it keeps the explosion falloff, the force of a melee move and the rhythm of fire ticks, which an absolute value would break.
- The class is the weapon the damage arrives with (51 explosion, 37 fire), not its cause: the victim cannot tell the source of an explosion or a fire apart. The body part does not take part: it does not change the damage to a player.
- The melee floor and the Predator bullet floor are multiplied by the class multiplier. Until the victim's client has acknowledged the last table sent to it, and for 2 s after that, the floor takes the lowest multiplier it may still be applying.
- 0 refuses the hit entirely, as for a teammate: otherwise a forced death would kill with a melee weapon dealing no damage.
Death by the ledger
The server commits the death itself when the ledger's health reaches 0 (by a report, a frame or set_health(0)), on an accepted vehicle explosion for its occupants (except the killer's teammates while friendly fire is off: their clients refuse its damage), and on an accepted stealth kill. It waits for the victim's DeathReport for 2 × RTT + 1 s (RTT counted up to 1 s) from the moment the server counted the death; a stealth kill adds 10 s, because its paired animation can take that long to start. Without a DeathReport in time the server commits the death itself: player_death with the killer from the last damage, PlayerWasted with a default pose to everyone else, and SetHealth(0) to the victim.
- That
SetHealth(0)is critical, and the victim gets a respawn only after its client acknowledged it: a late zero would otherwise kill the new life. - From then on the player is dead for the server: its actions are refused, its body lies in the relay, and only a respawn grant revives it.
- Leaving the vehicle after an accepted explosion does not cancel the death: a client answers an explosion with an exit faster than any window an honest exit in flight would need. Of two counted deaths, the earlier deadline applies.
- A player who leaves within the wait leaves dead: its death is committed before its slot is freed.
No corrections to a living player
The server never sends "lower your health back" and never returns the damage of a rejected report. An absolute write races with damage in flight (the measurement above), and a loss an honest client saw was caused by an accepted shot or by the shared world. A divergence ends in a death by the ledger.
Sources of damage
Canonical: what the server decides. Claimed: what it accepts from the victim within its limits. Open: what cannot be checked without a collision world.
| Source | Canonical | Claimed by the victim | Open | Ledger |
|---|---|---|---|---|
| Hitscan on foot and from a vehicle | the shot: weapon, ammo, rate, start and length of the ray, rewind, correlation; the floor | that it hit, body part, loss | a skipped hit (indistinguishable from a wall), left to god_mode statistics | floor |
Vehicle guns (VehicleShot) | the volley, model, rate, ray correlation; the floor | that it hit, body part, loss | the same | floor |
| Melee, gun butt | attacker alive, weapon, ≤ 6 m, rate; the floor of the weakest blow | that it hit, body part, loss | a skipped hit | floor |
| Stealth kill | the whole decision | the pose | — | death by the server |
| Projectiles, satchels, gamemode explosions, Rhino cannon | cause, window, radius, scope | that it hit, amount | the amount (the point and the obstacles are in the clients' physics) | observation |
| Fire, spray, extinguisher | cause and window | that it hit, amount, summed per 250 ms | the amount | observation |
| Ramming, running over, rotor blades | driver, distance, vehicle motion | that it hit, amount | the amount | observation |
| Explosion of a vehicle with occupants | the accepted VehicleExplode | — | — | death by the server |
| Falls, drowning, unowned fire and explosions | cause in the list, rate | everything | skipping it (no falls, no drowning) only benefits the victim itself | observation |
Gamemode commands (set_*, add_*, create_explosion, create_fire) | value and revision; creator of the cause | — | — | canonical |
| Healing by the game itself, without the server (if any) | — | — | — | the raise is cut and flagged |
| Friendly fire | the damage group | — | — | refused on both clients and on the server |
Trust in fields
- Trusted: the ledger's health and armor, the health revision,
alive/death_epoch, the moment of death, the killer from the last damage, grants. - A damage report: attacker, weapon and order only after correlation; the body part from a whitelist, with no effect on the floor; "before", "after" and the loss within the floor and the ledger; the revision echo no newer than the last sent; the report number monotonic.
- A frame: health and armor only lower the ledger; the echoed report number only affects the order.
DeathReport: the pose from a whitelist; a death without it gets the default pose.
Loss, order, reconnection
| Case | Behavior |
|---|---|
| A hit report (unreliable) is lost | the frame lowers the ledger itself after 1 s; the hit has no attribution and no floor |
| A lethal report | travels on the reliable lane ahead of the DeathReport |
| A frame arrives before its report (32 % of frames with 0–30 ms jitter in a measurement) | it waits for the report (see the client's order) |
| A report created before a health command arrives after it | attribution only (see the revision) |
| A duplicate | rejected by the report number and the shot credit |
| Mutual kill | both shots were relayed before either death, so both die; after a death the server committed, the dead player's shots are refused |
| Reconnection | a new life from the start; carrying health across sessions is gamemode policy, through storage (ADR 0054) and set_health |
| Rewind | unchanged; the floor uses the weapon properties revision of the shot |
Consequences
- Wire:
PlayerSyncframes echo the health revision and the last report number; damage reports echo the health revision; health and armor commands carry a revision. god_modestays for skipped hits; the ledger closes the infinite-health trainer, reduced damage and immortality for everyone else.- A floor that is too high kills an honest player early, so each floor is set below the game's own damage of its weapon and measured against it.
Alternatives considered
- Keep health victim-authoritative. The infinite-health trainer and reduced damage stay working for everyone; the gamemode learns of them only from a suspicion flag.
- Fully server-side health with corrections. The server cannot compute the amount of an explosion, a fire, a ram or a fall without physics and collisions: honest players would get corrections.
- The hit decided by the shooter's word. A cheating shooter hits through walls (see ADR 0041). The same holds for switching to the shooter's word after a
god_modeflag: a single shooter could frame a victim. - A fixed time window around each health command instead of revisions. The race depends on delivery order, not on time; a window of any length is crossed at high latency (the measurement above).
- An absolute damage value per class instead of a multiplier. Breaks the explosion falloff and the rhythm of fire.
- A time window instead of acknowledgements for a multiplier change. A critical event behind a lost packet waits for delivery until the link breaks (15 s), and a floor at the new multiplier would deduct damage the victim never saw.
- A per-command
maxfor absolute sets. No gamemode needs a ceiling above 200 yet.
