Understand and work with the combat system in Oxidus. Covers the attack loop, hit-chance formula, damage pipeline, threat tracking, defense/AC, procs, vitals/regen, death sequence, XP/advancement, NPC combat behavior, and combat memory.
You are helping work with the Oxidus combat system. Follow the lpc-coding-style skill for all LPC formatting.
body.c
└── combat.c — attack loop, threat, hit chance, AC, defense
└── damage.c — deliver/receive damage formulas
vitals.c — HP/SP/MP, regen, condition strings
advancement.c — per-living XP/level state
npc.c — NPC heartbeat, death detection, setLevel override
proc.c (module) — weapon proc system
combat_memory.c (module) — NPC "remember and attack on sight"
advance.c (daemon) — TNL formula, killXp, earnXp, advance
death.c (daemon) — signal listener for SIG_PLAYER_DIED/REVIVED
body.c → combat.c → damage.c
npc.c → body.c (STD_BODY)
combat_round() is called via call_out_walltime at intervals of _attack_speed + random_float(1.5) seconds (base speed default: 2.0).
combat_round()
├── clean_up_enemies() — remove dead/gone enemies
├── highest_threat() → enemy — pick target with most accumulated threat
├── valid_enemy(enemy) — must be in same room and alive
├── swing(1, 0) — execute attack(s)
├── GMCP Char.Status update — current_enemy, current_enemy_health, current_enemies
└── next_round() — schedule next combat_round
swing)Each swing checks for a dual-wield opportunity:
if(random(100) < 5 + query_skill("combat.melee"))
multi = 1; // next recursive call uses off-hand weapon
When multi is set, a random weapon from non-main-slot weapons is used. The function recurses with decremented count.
MP is checked before each swing — if query_mp() <= 0.0, the attacker is "too exhausted" and the swing is skipped.
can_strike()float chance = mudConfig("DEFAULT_HIT_CHANCE"); // 65.0
if(enemy->query_mp() < 0.0)
chance += 25.0; // exhausted enemies are easier to hit
chance = chance
+ (attacker_level - defender_level) // effective levels
+ attacker_skill_level // weapon skill (query_skill_level)
- (defender_ac * 2.0) // armor class doubled
- defender_defense_skill_level; // dodge or evade skill
result = random_float(100.0);
enemy->use_skill(defense_skill); // defender trains defense on every attempt
return result < chance;
Skill routing by weapon type:
| Weapon | Attacker Skill | Defender AC | Defender Skill |
|---|---|---|---|
| Object or unarmed | "combat.melee.<damage_type>" | query_ac() | "combat.defense.dodge" |
Spell (string contains ".spell.") | spell skill name | query_spell_ac() | "combat.defense.evade" |
| Other string skill | skill name | query_spell_ac() | "combat.defense.dodge" |
strike_enemy()base = percent_of(5.0, enemy->query_max_hp()); // 5% of enemy max HP
variance = percent_of(25.0, base); // 25% of that
base -= variance;
base += random_float(variance); // restore 0..variance randomly
if(enemy->query_mp() < 0.0)
base += 4.0; // bonus vs exhausted
dam = base
+ query_effective_level() // attacker level
- enemy->query_effective_level() // defender level
+ skill // attacker weapon skill level
- enemy->query_defense_amount(weapon_type) // type-specific armor reduction
- enemy->query_skill_level("combat.defense"); // generic defense skill
if(dam < 0.0) dam = 1.0; // minimum 1 damage
After calculating damage:
MESS_D->get_message("combat", wtype, to_int(ceil(dam))) and ACTION_D->action(...).deliver_damage(enemy, dam, wtype) — dispatches to enemy.adjust_mp(-random_float(5.0)) — each swing costs 0-5 MP.add_threat(enemy, dam) — threat grows by damage dealt.weapon->can_proc() then weapon->proc(name, self, enemy).receive_damage()def = query_defense_amount(type); // type-specific armor value
red = percent_of(def, damage); // def% of damage is reduced
mod = mudConfig("DAMAGE_LEVEL_MODIFIER"); // 25
level_difference = attacker_level - defender_level;
mod = mod * level_difference;
red -= percent_of(mod, damage); // level gap reduces/increases armor effectiveness
damage -= red;
if(damage < 0.0) damage = 0.0;
Then applies adjust_hp(-damage) and sets last_damaged_by / killed_by as appropriate.
Level modifier effect: Higher-level attackers reduce the defender's armor effectiveness. Lower-level attackers increase it.
Threat values start at 1.0 on start_attack() and grow by damage dealt.
| Function | Description |
|---|---|
add_threat(ob, float) | Increments _current_enemies[ob] |
add_seen_threat(ob, float) | Increments _seen_enemies[ob] (persists across rounds) |
highest_threat() | Returns enemy with highest threat — combat target |
lowest_threat() | Returns enemy with lowest threat |
_current_enemies is the active combat mapping. _seen_enemies tracks all-time threat.
adjust_protection()Called whenever equipment changes. Rebuilds _defense and _ac from all equipped items:
mapping adjust_protection() {
// iterates query_equipped() values
// sums ob->query_defense() mappings into _defense per damage type
// sums ob->query_ac() into _ac
}
| Function | Description |
|---|---|
query_ac() | Returns aggregated _ac float |
query_defense_amount(string type) | Returns _defense[type] for a specific damage type |
set_defense(mapping) | Directly sets _defense |
add_defense(string type, float amount) | Adds a single type entry |
start_attack(object victim)_current_enemies with threat 1.0._seen_enemies.module("combat_memory", "add_to_memory", victim).combat_round via call_out_walltime.victim->start_attack(this_object()) — mutual engagement.stop_all_attacks()Cancels combat call out, clears _current_enemies, sends GMCP clear.
prevent_combat(object victim)Returns error string if:
victim->query_peaceful() is setvictim->query_no_combat() is setenvironment()->query_no_combat() is setReturns 1 if combat is allowed.
Used when the NPC has no wielded weapon object:
| Function | Default | Description |
|---|---|---|
set_damage(float) | 0.0 | Base damage. If <= 0, uses random_float(queryLevel() * 2.0) |
set_weapon_name(string) | "fist" | Display name for combat messages |
set_weapon_type(string) | "bludgeoning" | Damage type string |
query_weapon_info(weapon) transparently handles both real weapon objects and NPC defaults, returning ([ "name", "type", "skill", "base" ]).
std/modules/proc.cWeapons inherit this module to add special effects on hit.
_procs[name] = ([
"function" : string | function, // required
"cooldown" : int, // seconds, default 1
"weight" : int, // selection weight, default 100
"chance" : int, // optional per-proc chance override
])
| Function | Description |
|---|---|
add_proc(string name, mixed proc) | Register a proc (mapping, string, or function) |
set_procs(mixed *procs) | Batch add: ({ ({ name, proc }), ... }) |
set_proc_chance(float) | Global proc chance, 0.0-100.0 (default 15.0) |
can_proc() | Called by strike_enemy. Filters by cooldown, selects via element_of_weighted. Returns proc name or false |
proc(string name, mixed args...) | Executes the proc function with (attacker, victim) args. Records cooldown |
// In strike_enemy():
if(weapon && weapon->is_weapon())
if(stringp(proc = weapon->can_proc()))
weapon->proc(proc, this_object(), enemy);
std/living/vitals.cAll private nomask float, defaults 100.0:
| Variable | Description |
|---|---|
hp / max_hp | Hit points |
sp / max_sp | Spirit/mana points |
mp / max_mp | Movement/stamina points |
dead | int, death flag |
query_max_hp(), query_max_sp(), query_max_mp() all add boon modifiers unless called with raw = 1:
return max_hp + query_effective_boon("vital", "max_hp");
hp_ratio(), sp_ratio(), mp_ratio() return percentage floats.
heal_tick()regen_interval_pulses heartbeats (default: 10 pulses).in_combat() check).module("race", "query_regen_rate").query_condition_string() returns ({ hp_string, sp_string, mp_string }):
HP: "dead" (0) → "critical" (10) → "severely injured" (30) → "moderately injured" (45) → "injured" (60) → "hurt" (75) → "wounded" (90) → "bruised and nicked" (<100) → "healthy" (100)
SP: "brain dead" (5) → "depleted" (15.5) → ... → "fully charged" (100)
MP: "exhausted" (5) → "sluggish" (15.5) → ... → "full of stamina" (100)
body.c::die()Death is detected in the NPC heartbeat:
if(!is_dead() && query_hp() <= 0.0) {
set_dead(1);
die();
}
die() Flowstop_all_attacks() — clear combat statesimple_action("$N $vhave perished.") — room messagesave_body() — persist player stateemit(SIG_PLAYER_DIED, this_object(), killed_by()) — signalnew(LIB_CORPSE) → corpse->setup_corpse(self, killer)LOOT_D->loot_drop(killed_by(), self) if query_loot_table existsLOOT_D->coin_drop(killed_by(), self) if query_coin_table existsBODY_D->create_ghost(privs), exec(ghost, self), move ghost to roomADVANCE_D->killXp(killed_by(), self) — award XP to killerremove() — destroy this objectadm/daemons/advance.ctoNextLevel(level) = to_int(baseTnl * pow(tnlRate, level - 1.0))
// Default: to_int(100 * 1.25^(level-1))
| Level | TNL |
|---|---|
| 1 | 100 |
| 2 | 125 |
| 5 | 244 |
| 10 | 931 |
| 20 | ~8674 |
xp = toNextLevel(killed_level) / 10; // 10% of killed NPC's TNL
variance = xp / 10;
xp = xp - variance + random(variance); // +/- 10% random
level_diff = killer_level - killed_level;
if(level_diff > 5) // overlevel
factor -= 0.05 * (level_diff - 5); // -5% per level over threshold
else if(level_diff < 0) // underlevel
factor += 0.05 * (-level_diff); // +5% per level under
xp = to_int(xp * factor);
If PLAYER_AUTOLEVEL is true (default), advance() is called immediately after XP award.
| Key | Default | Used In |
|---|---|---|
DEFAULT_HIT_CHANCE | 65 | can_strike() |
DAMAGE_LEVEL_MODIFIER | 25 | receive_damage() |
BASE_TNL | 100 | toNextLevel() |
TNL_RATE | 1.25 | toNextLevel() |
OVERLEVEL_THRESHOLD | 5 | killXp() |
OVERLEVEL_XP_PUNISH | 0.05 | killXp() |
UNDERLEVEL_THRESHOLD | 0 | killXp() |
UNDERLEVEL_XP_BONUS | 0.05 | killXp() |
PLAYER_AUTOLEVEL | true | earnXp() |
HEART_PULSE | 2000 | regen interval |
HEARTBEATS_TO_REGEN | 5 | regen interval |
DEFAULT_HEART_RATE | 10 | NPC heartbeat |
std/living/npc.cNPCs only tick when players are present:
void start_heart_beat() {
if(player_check())
set_heart_beat(mudConfig("DEFAULT_HEART_RATE"));
}
void stop_heart_beat() {
if(!player_check() && query_hp() >= 100.0)
set_heart_beat(0);
}
Heartbeat loop: clean_up_enemies() → cooldown() → death check → heal_tick() → evaluate_heart_beat() → process_boon().
Automatically added to all NPCs. Remembers enemy names.
On init (player enters room), if target name is in memory:
void attack_on_sight(object target) {
if(of(name, combat_memory)) {
query_owner()->start_attack(target);
query_owner()->strike_enemy(target); // immediate free strike
query_owner()->strike_enemy(target); // second free strike
}
}
Memory is populated from combat.c::start_attack():
if(!userp())
module("combat_memory", "add_to_memory", victim);
| Package | When | Fields |
|---|---|---|
Char.Status | Every combat round | current_enemy, current_enemy_health ("%.2f" ratio), current_enemies |
Char.Status | Combat ends | current_enemy: "", current_enemies: ({}) |
Char.Vitals | Every adjust_hp/sp/mp | hp, sp, mp (as "%.2f") |
Char.Status | Level/XP changes | xp, tnl, level |
| Signal | When | Payload |
|---|---|---|
SIG_PLAYER_DIED | die() after corpse creation | (self, killed_by) |
SIG_PLAYER_REVIVED | External revival | (self) |
SIG_PLAYER_ADVANCED | advance() on level-up | (tp, new_level) |
| Skill | Used In |
|---|---|
"combat.melee" | Multi-strike chance in swing() |
"combat.melee.<type>" | Hit/damage formulas (can_strike, strike_enemy) |
"combat.melee.unarmed" | Unarmed fallback |
"combat.defense.dodge" | Melee defense in can_strike() |
"combat.defense.evade" | Spell defense in can_strike() |
"combat.defense" | Generic defense reduction in strike_enemy() |
| Function | Description |
|---|---|
set_attack_speed(float) | Directly sets base speed |
add_attack_speed(float) | Adjusts, clamped to [0.5, 10.0] |
query_attack_speed() | Returns _attack_speed |
Actual interval per round = _attack_speed + random_float(1.5) seconds.
receive_damage. The dead flag is set in npc.c::heart_beat() when query_hp() <= 0.0. There can be a brief window between HP hitting zero and die() firing.heal_tick() returns immediately if in_combat().query_skill() vs query_skill_level(): For NPCs, query_skill() returns queryLevel() * 3.0 (shortcut), but query_skill_level() reads stored values. Combat formulas use query_skill_level().setLevel() on NPCs wipes skills: npc.c::setLevel() calls adjust_skills_by_npc_level() which resets all stored skill levels to near-zero._proc_chance field is maintained but not rolled against in can_proc() — the actual selection uses per-proc cooldowns and element_of_weighted.highest_threat() targets whoever has dealt the most damage to this living.