| name | combat-system |
| description | 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. |
Combat System Skill
You are helping work with the Oxidus combat system. Follow the lpc-coding-style skill for all LPC formatting.
Architecture Overview
body.lpc
โโโ combat.lpc โ attack loop, threat, hit chance, AC, defense
โโโ damage.lpc โ deliver/receive damage formulas
vitals.lpc โ HP/SP/MP, regen, condition strings
advancement.lpc โ per-living XP/level state
npc.lpc โ NPC heartbeat, death detection, setLevel override
proc.lpc (module) โ weapon proc system
combat_memory.lpc (module) โ NPC "remember and attack on sight"
advance.lpc (daemon) โ TNL formula, killXp, earnXp, advance
death.lpc (daemon) โ signal listener for SIG_PLAYER_DIED/REVIVED
Inheritance Chain
body.lpc โ combat.lpc โ damage.lpc
npc.lpc โ body.lpc (STD_BODY)
Combat Round Loop
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
Multi-Strike Logic (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.
Hit Chance Formula โ 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" |
Damage Formula โ 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:
- Combat messaging via
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 proc check:
weapon->can_proc() then weapon->proc(name, self, enemy).
Damage Reception โ 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 System
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.
Defense / AC System โ 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 |
Starting and Stopping Combat
start_attack(object victim)
- Adds victim to
_current_enemies with threat 1.0.
- Adds to
_seen_enemies.
- For NPCs:
module("combat_memory", "add_to_memory", victim).
- Schedules
combat_round via call_out_walltime.
- Calls
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 set
victim->query_no_combat() is set
environment()->query_no_combat() is set
Returns 1 if combat is allowed.
NPC Weapon Properties
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" ]).
Weapon Proc System โ std/modules/proc.lpc
Weapons inherit this module to add special effects on hit.
Proc Data Structure
_procs[name] = ([
"function" : string | function, // required
"cooldown" : int, // seconds, default 1
"weight" : int, // selection weight, default 100
"chance" : int, // optional per-proc chance override
])
Key Functions
| 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 |
Integration
// In strike_enemy():
if(weapon && weapon->is_weapon())
if(stringp(proc = weapon->can_proc()))
weapon->proc(proc, this_object(), enemy);
Vitals โ std/living/vitals.lpc
Variables
All 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 Functions
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.
Regen โ heal_tick()
- Fires every
regen_interval_pulses heartbeats (default: 10 pulses).
- Suppressed during combat (
in_combat() check).
- Rates come from race module:
module("race", "query_regen_rate").
- Human defaults: HP +2.0, SP +2.0, MP +4.0 per tick.
- No race module = no regen.
Condition Strings
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)
Death Sequence โ body.lpc::die()
Death is detected in the NPC heartbeat:
if(!is_dead() && query_hp() <= 0.0) {
set_dead(1);
die();
}
die() Flow
stop_all_attacks() โ clear combat state
- SU body ejection (if a developer is possessing this body)
simple_action("$N $vhave perished.") โ room message
save_body() โ persist player state
emit(SIG_PLAYER_DIED, this_object(), killed_by()) โ signal
- Create corpse:
new(LIB_CORPSE) โ corpse->setup_corpse(self, killer)
- Loot drop:
LOOT_D->loot_drop(killed_by(), self) if query_loot_table exists
- Coin drop:
LOOT_D->coin_drop(killed_by(), self) if query_coin_table exists
- Move all inventory to corpse
- Move all wealth to corpse (converted to coin objects)
- Move corpse to room
- Player path:
BODY_D->create_ghost(privs), exec(ghost, self), move ghost to room
- NPC path:
ADVANCE_D->killXp(killed_by(), self) โ award XP to killer
remove() โ destroy this object
XP and Advancement โ adm/daemons/advance.lpc
TNL Formula
toNextLevel(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 |
Kill XP Formula
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.
Config Constants
| 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 |
NPC Combat Behavior โ std/living/npc.lpc
Heartbeat
NPCs 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().
Combat Memory Module
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.lpc::start_attack():
if(!userp())
module("combat_memory", "add_to_memory", victim);
GMCP Events
| 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 |
Signals
| 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) |
Combat Skill Names
| 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() |
Attack Speed
| 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.
Gotchas
- Death is detected in heartbeat, not in
receive_damage. The dead flag is set in npc.lpc::heart_beat() when query_hp() <= 0.0. There can be a brief window between HP hitting zero and die() firing.
- No regen during combat.
heal_tick() returns immediately if in_combat().
- NPCs stop ticking in empty rooms. No heartbeat = no regen, no boon processing, no AI. Developers expecting continuous background behavior need to understand this.
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.lpc::setLevel() calls adjust_skills_by_npc_level() which resets all stored skill levels to near-zero.
- Proc
_proc_chance field is maintained but not rolled against in can_proc() โ the actual selection uses per-proc cooldowns and element_of_weighted.
- Threat is accumulated damage, not an abstract aggro value.
highest_threat() targets whoever has dealt the most damage to this living.