Skip to content

Combat

The server owns every player’s health, stamina and injuries. A player’s game only reports what its swing or its arrow hit - the game’s own hit record - and the server decides what that did, tells the mode, and tells every client what to show. The numbers come from [combat] in server.toml (Server configuration); the game’s own weapon and armour tables give the damage when the server has the tables export ([combat] tables).

  1. The attacker’s client reports the hit: the target, the attack zone, the body part struck, and - for an arrow, a bolt or a ball - the shot it belongs to.
  2. The server checks it is plausible: the two are within max_reach, the claim follows a swing the attacker announced within claim_window_ms, a shot was actually fired (shot_interval_ms, the ammunition in the shooter’s inventory), the two are allowed to fight (pvp, not teammates, the same virtual world).
  3. The damage is computed. Melee: the attacker’s equipped weapon’s Attack × the type’s modifier (stab, slash or smash) × attack_scale, reduced by the armour the target wears on the struck part against that type (d / (d + armour_pivot), 85 % at most), × the part’s coefficient (head 1.5, torso 1, limbs 0.7), less when the swing was thrown tired, × damage_scale. Ranged: the ammunition’s attack × the launch speed (the draw - a snap shot at half speed does a quarter) × ranged_scale, then the same armour and part rules; every arrow is a stab. Bare hands do fist_damage. An attacker whose weapon is sheathed hits with fists whatever the claim says. Without the tables the server takes the attacker’s own number, clamped to max_claim_damage.
  4. The mode hears OnPlayerDamage(pid, attacker, damage, zone, part, weapon, dtype) and may change the number, zero it or cancel the hit.
  5. The damage comes off the health; the victim’s stamina takes hit_stamina_damage; a hit of at least injury_threshold injures the part (OnPlayerInjury); a stab or a slash opens a bleed of bleed_per_damage × damage health per second (max_bleed at most) for bleed_seconds; at 0 health the player dies (OnPlayerDeath) and every client shows it.

The server log names every hit: Henry#0 hit Hans#1 for 27.2 (shortswordBroad slash, raw 30.5, armour 6; zone 3, torso): health 72.8, torso injured, bleeding 0.41/s.

A swing costs attack_stamina_cost (× 1.2 for a slash or a smash, × 0.5 bare-handed, × 1.25 with an injured arm); a swing thrown with less than it costs lands at 35-100 % of its damage. A hit taken costs hit_stamina_damage, a swing blocked block_stamina_cost (a perfect block costs the blocker nothing and the attacker perfect_block_stamina), a shot shot_stamina, a sprint sprint_stamina_cost per second, a jump jump_stamina_cost. It regenerates at stamina_regen per second after 1.5 s without any of those - three quarters of it while walking or running, as the game does, and halved with an injured head or torso. The bar the player sees is this number: GetPlayerStamina / SetPlayerStamina.

The game’s own special moves work between players, and each one is the defender’s game’s call: the window the attacker’s swing opens and the key pressed inside it decide the move on the defender’s screen, the server hears of it, and every other screen plays the same move against the same swing.

  • A perfect block - the block timed to the blow - makes that swing harmless: it never comes to OnPlayerDamage, and its attacker pays perfect_block_stamina.
  • A master strike - a counter into the swing with the guard turned to meet it - parries it the same way and cuts back. The counter is the defender’s hit on the attacker, on the torso and × master_strike_scale, and comes to the mode as an ordinary OnPlayerDamage with the defender as attacker. It always plays the plain animation, never the killing one: whether it kills is the damage’s call, as with any other hit. With master_strike = false the players’ games offer no master strike (nor the disarm) and only the perfect block is left.
  • A clinch - two players whose blades lock - pairs them on every screen at once: the one who leads it strikes inside the clinch (a pommel, a shield bash, a throw) and the other may counter a blow with a block timed to it, in the game’s own slow motion. A clinch blow is the striker’s hit on the victim’s torso, × clinch_scale, and comes to the mode as an ordinary OnPlayerDamage; a countered one never comes. The server pairs two players only with pvp on, while they fight each other in the same virtual world, stand within 3 metres and neither rides, and not within a second of either one’s last clinch; clinch = false never pairs them, and they fight on.
  • A counter after a perfect block, or any other special attack, is an ordinary swing and an ordinary hit, and the one it is aimed at can perfect-block it in turn - parry, counter, parry, counter. Every screen shows the attack the attacker’s game chose.
  • A plain block is the defender’s call as well (victim_blocks): a blow their own game blocked does no harm and never comes to OnPlayerDamage, whatever the attacker’s screen showed a moment later. Armour is no block: a blow that heavy armour takes whole in either player’s game still lands, and step 3 prices it.
  • In fact every melee blow between two players is the defender’s game’s call (victim_blocks): it lands when their game shows it land - at the body part it struck there - is blocked when their guard met it, and misses when their game never shows the blade reach them. The attacker’s game sees its swing a moment earlier, on its own copy of the defender, and that copy never stands exactly where the defender does; the defender’s screen is the one where blocking happens. A blow the attacker saw land waits up to verdict_window_ms for the defender’s game, then counts as a miss (unanswered_blow = "land" takes the attacker’s word instead). The price is always the server’s: step 3, never a number either game sent.

A player whose game has judged no blow yet this session (a bot, a client that cannot) is hit as the attacker saw it: the blow waits for that defender’s word - their round trip, at most parry_window_ms, or counter_parry_window_ms for the first swing after the attacker’s own perfect block (a counter, which its victim sees a moment later) - and lands when none comes. A clinch blow waits up to clinch_parry_window_ms for the victim’s counter. Either way OnPlayerDamage comes a moment after the blow. Against an NPC actor’s swing the player’s game settles the block itself - with [validation] strict the server also lands a swing the player’s game never answers, unless its own record shows the player perfectly blocked it, blocked it, stepped away or was out of the actor’s reach (the validation settings) - and a master strike on an actor comes as OnActorDamage. [combat] perfect_block = false switches the moves off on the server: a blow counts as the attacker’s game saw it land.

Two players are in a fight - and only then may either one’s game lock on to the other’s body (the direction indicators, the combat stance) - once a swing of one reaches the other’s body, after /fight <name>, or when the mode says so. Without a fight two players walking up to each other stay at peace. A fight ends after fight_timeout seconds without a blow between the two (0 = never by time), on a death, on /peace (ends every fight of the caller - its fights with NPC actors too, which stand down), on a disconnect, or when the mode ends it. Both players read a chat line at each change; the mode hears OnFightStart / OnFightEnd.

The mode’s side of it: StartFight (on a running fight it restarts the timeout clock - a duel mode calls it every second so two circling duelists never lose the lock), EndFight, AreFighting, GetPlayerOpponents, GetPlayerWeapon.

[combat] pvp = false keeps a co-op party from locking on to or hurting each other: no fight can start and every hit between players is refused.

SetPlayerTeam(pid, team) puts players in teams; a hit between two players of one team is refused before OnPlayerDamage and starts no fight. NO_TEAM (-1) is no team. A mode that wants friendly fire on writes its rule in OnPlayerDamage instead of using teams.

A bow, a crossbow or a gun works through the server like a sword: the shooter’s client reports the shot as it leaves the weapon (the ammunition, the speed - which is the draw), the server admits it (a bow among the equipment, the ammunition in the inventory, shot_interval_ms between shots, shot_stamina), every client in range flies a look-alike, and the hit is the shooter’s claim within the flight time plus shot_window_seconds. OnPlayerDamage names the weapon as "bow_c + arrow_normal" and the type as "stab". The archer’s strength limits the draw as in the game: the server reads the level the archer’s game reports, which a mode sets exactly - lower too - with SetPlayerStat ([spawn] stat_level only ever raises it).

A player’s hit on an NPC actor goes through the same model (no armour on the actor) and comes to the mode as OnActorDamage; an actor’s blow on a player is priced by the victim’s own game (the actor’s weapon through their armour) × [actors] attack_scale and comes as OnActorAttack. An armed actor raises its guard against a swing part of the time ([actors] block_chance, block_hold) and swings where the opponent’s guard is not; its wounds and bleeding follow the same [combat] keys as a player’s (OnActorInjury, HealActor).

Everything the mode needs is in the callback: cancel, scale, redirect, log.

-- no damage in the lobby zone, double damage to the head, a log line for every kill
function OnPlayerDamage(pid, attacker, damage, zone, part, weapon, dtype)
if IsPlayerInZone(pid, lobby) then return false end
if part == BODY_PART_HEAD then return damage * 2 end
end
function OnPlayerDeath(pid, attacker)
if attacker >= 0 then
Log(GetPlayerName(attacker) .. " killed " .. GetPlayerName(pid) .. " with " .. GetPlayerWeapon(attacker))
end
end

HealPlayer(pid) makes a player whole; SetPlayerHealth(pid, 0) kills one; [combat] respawn_seconds = 0 leaves the respawn to the mode’s SpawnPlayer.