Repository navigation
Mechanics Combat
TL;DR: Melee and spells run through two damage pipelines that apply protections in a fixed order. Defences are rolled before damage. Lag is counted in pulses (
wait,daze) and stun is a pair of affect bits. Spell level is the caster level minus a class penalty plus bonuses. Use when: you change anything that modifies damage, add a defence, set lag from a script, or need to know what a combat number means. Source of truth:plug-ins/fight/onehit.cpp,plug-ins/fight/onehit_undef.cpp,plug-ins/fight_core/damage.cpp,plug-ins/fight_core/immunity.cpp,plug-ins/fight_core/character.cpp(setWait,setDaze),plug-ins/skills_impl/defaultspell.cpp(getSpellLevel),plug-ins/loadsave/tohit.cpp,dreamland_world/fight/to_hit.json. Verified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467, dreamland_fenia_public c28874b
A combat round is 3 seconds (12 pulses). violence_update walks a snapshot of the character list, gives each fighter one round (multi_hit), then runs onFight on the character and its worn items. See Engine-Time-and-Pulses. A character who dies mid-round is skipped.
Inside a round, check_stun runs before any attack. A character that is stunned or weak stunned loses the round (see Wait, daze and stun).
Before you add a damage modifier, find the pipeline it belongs to. The class names are misleading in one place.
| Pipeline | Entry | Used by |
|---|---|---|
OneHit::hit() |
no arguments | every melee blow: the ordinary round (UndefinedOneHit), backstab and the other weapon skills (SkillWeaponOneHit family), rear kick, vampiric bite |
Damage::hit(bool) |
show flag |
every spell, Fenia ch.damage and affect damage, rawdamage, self-damage |
AssassinateOneHit is a SkillDamage, so it goes through the second pipeline despite its name.
UndefinedOneHit::calcDamage builds its own modifier list and calls protectAll itself. It never calls the base OneHit::calcDamage or Damage::calcDamage. The special weapon attacks have their own calcDamage and some of them do chain to Damage::calcDamage. A modifier added to a base calcDamage therefore skips the ordinary round and can apply twice to special attacks. Hook OneHit::hit() for melee or Damage::hit() for everything.
OneHit::hit() does, in order:
-
canHitand movement cost. - To-hit roll (see To-hit and armor). A miss ends the blow.
-
calcDamage: weapon dice, damroll, strength, skill and class modifiers, then all protections (protectAll), then the material immunity of the wielded weapon. -
damApplyPosition: once, after the protections and every other multiplier. Victim asleep or worse: damage x2. Victim resting or sitting (belowPOS_FIGHTING): x1.5. Melee only, spells are excluded on purpose.orig_dam, the pre-protection value that vampiric drain reads, gets the same factor. -
canDamage: the defence chain. - Pre-damage effects, the message,
inflictDamage, post-damage effects.
Damage::protectAll applies these in order. Each works on the damage left by the one before it.
| Step | Effect |
|---|---|
| Sanctuary | damage / 2 for sanctuary or stardust. A dark shroud takes 40% off, or 50% for clan guard mobs. Without any of them, the resistance spell takes 40% off. |
| Alignment | protection from evil or good: -25% when the attacker has the opposed alignment |
| Temperature | protection from heat or cold: -25% against fire or cold damage |
| Liturgy | a small cut that grows with the victim's level |
| Immunity | see Damage types and immunity |
| Flat trim | any hit above 10 is multiplied by 4/5 |
| Armor | see To-hit and armor |
Damage::protectAll is the single list. To add a protection, add a step there. A Fenia onImmune handler on the victim, the mob prototype or an affect runs before the table check and can zero the hit.
gprog catches ::Exception, and VictimDeathException derives from it. A Fenia handler that calls ch.damage() and kills the target would have the death swallowed, and the round would keep hitting a corpse. Combat call sites use the non-catching gprog_nocatch and rethrow the death exception. Do the same for any new C++ to Fenia call on a combat path. ch.rawdamage and ch.damage can throw on a kill, ch.spell() is the catching variant.
Every melee blow that hits goes through one chain in UndefinedOneHit::canDamage. The first defence that succeeds ends the blow. Order, outermost first:
bat swarm -> shield block -> blink -> cross block -> parry -> hand block -> dodge
- Hand block is C++. Shield block, cross block, bat swarm, blink, parry and dodge are skill scripts.
- Bat swarm checks an affect, not a skill.
-
getEffective(ch) <= 1means "has the skill by level but never practised it". A learned skill reads as at least 1 for any available skill, never 0. The defences refuse at 1 or below, so a new defence should do the same. - NPCs never consult the skill. An NPC's dodge is a flat 50, and NPCs do not hand block. A new gate needs an
is_npccheck. - Weak stun halves the chance of dodge, parry, blink and hand block. A full stun makes hand block fail outright. For a character that was stunned this round,
check_stunhas already cancelled the attack. - A samurai caught outside a fight can draw and cut before the first blow lands (opener).
The armor model is switched on by model in dreamland_world/fight/to_hit.json. With it off the old THAC0 and AC rules apply. All the numbers below are in that file and are read at load, so retuning does not need a rebuild.
Hit chance:
accuracy = base_w * classBase + effective hitroll
evasion = ev_level * victim level + ev_dex * (-dex defensive) * min(1, victim level / 100)
P = accuracy^hit_pow / (accuracy^hit_pow + evasion^hit_pow), clamped to p_floor..p_cap
classBase is 20 - interpolate(level, thac00, thac32) from the profession, never below 1. Effective hitroll is hitroll * skill% * (1 + strength hit bonus). Evasion is halved for a victim below fighting position or with no movement points, and multiplied by blind_factor when the attacker is blind and fails blind fighting. The armor use skill adds armor_use_bonus armor per victim level on a successful use.
The roll: one in twenty is an automatic miss (hobbits critical instead) and one in twenty an automatic critical. The other 90% use P rescaled, so the overall hit rate equals P.
Armor reduces damage as the last protection. Bash, pierce and slash use their own armor column, and energy uses the exotic column, all four with the attacker's effective hitroll as pierce. Fire, cold, lightning, acid and light use the exotic column, scaled by spell_factor, with the attacker's level times spell_pierce_level standing in for hitroll. Other damage types are not reduced by armor.
k = dr_level * victim level + dr_pierce * effective hitroll
x = (armor / k)^dr_pow
reduction = min(dr_cap%, x / (1 + x))
The hit is never reduced by more than 99% in code, whatever dr_cap says.
Damage types (damage_table in plug-ins/fight_core/damageflags.conf): none, bash, pierce, slash, fire, cold, lightning, acid, poison, negative, holy, energy, mental, disease, drowning, light, other, harm, charm, sound. Every hit also carries damage flags: weapon, spell, magic, prayer, other, water.
immune_check(ch, dam_type, dam_flag) collects bits from the victim's imm_flags, res_flags and vuln_flags:
- If the hit has the weapon flag,
weaponis checked. - If it has the spell flag, any one of
spell,magicorprayercounts. Resisting one resists all spell damage. The prayer and magic flags check their own bit andspell. - The water flag checks
drowning. - Then the bit for the damage type itself:
bash,pierce,slash,fire,cold,lightning,acid,poison,negative,holy,energy,mental,disease,drowning,light,charm,sound. Typesnone,otherandharmhave no bit.noneskips the whole check.
Resolution: immune beats everything and zeroes the hit. Resistant and vulnerable on the same type cancel to normal. Resistant takes a third off, vulnerable adds half.
Facts that matter when you design an immunity:
- A melee blow always carries a physical type, even a punch with no attack noun (it counts as bash). So
imm_bashfully blocks a weapon hit, independently of the weapon flag. - Resists do not stack.
res_weaponplusres_bashon one bash hit set the same bit and cut once. - An immunity granted by worn equipment cannot be dispelled while the item is worn.
- Material immunity is a separate pass (
protectMaterial) for the wielded weapon. Each material indreamland_world/fight/material.jsonhas avulnfield that names an immunity bit (iron, wood, silver, mithril and so on). The victim's immune, resistant and vulnerable flags for that bit apply with the same rules. - Measured over all mobs, weapons, skills and spells in the world data (entity count, not damage taken), bash is about half of all damage-bearing entities, pierce and slash about a sixth each, and every elemental type about one percent. Physical damage dominates mob melee. Elemental damage reaches players mostly through spells and weapon procs. Do not price an elemental immunity from the entity count.
wait and daze are character fields counted in pulses (a quarter of a second each).
| State | Effect | Counts down |
|---|---|---|
wait |
the character's input is not parsed at all while it is above 0 | once per pulse in the input phase for a connected player, once per pulse in aggr_update for NPCs and link-dead players |
daze |
effective skill is halved for spells and cut to two thirds for other skills. A prone character is 25% less likely to stand up. | same as wait
|
C++ setters (setWait, setDaze, and the ...Violence variants that take combat rounds) never lower the current value: they keep the larger of the old value and the new one. An immortal always gets a wait of 1. Strong NPCs shrug off lag that other characters put on them: tier 1 keeps a quarter of the raise, tier 2 a third, tier 3 a half. Lag from the NPC's own commands is not reduced.
The Fenia fields ch.wait and ch.daze are different. They assign the value as given, so a script can shorten or clear lag, and the tier reduction applies only to a raise. To add lag without cutting a longer one already running, write ch.daze = .max(ch.daze, n). A bare ch.daze = n shortens a longer daze.
Stun is two bits in affected_by, stun and weak_stun. Check them through the affect_flags table.
- A stunned character cannot enter commands at all ("you are in no condition to do this") except commands marked to keep hiding.
- At the start of the stunned character's next combat round
check_stunruns. A full stun cancels the attack and turns into weak stun. A weak stun cancels the next attack and is removed. So a stun costs two rounds, or about six seconds, unless it is shrugged off earlier. - A stun caused by the paralysis affect gets a chance to end early: 15% per elapsed tick, plus 3% per point of Constitution above 20, plus 2% per level the stunned character has over the attacker, between 5% and 95%. On half of that chance it ends completely, otherwise it drops to weak stun.
- Outside combat,
violence_updateconverts a stun to weak stun and then clears it on the following rounds. - A stunned or weak stunned character has a lower chance to stand up after being knocked down: always fails when stunned, fails half the time when weak stunned.
- Weak stun halves the chance of several attack skills as well as the defences.
saves_spell(level, victim, damage type, caster, flags) decides whether a victim resists a spell. dreamland_world/fight/saves.json picks the model with its model key. Model 0 is the legacy roll. Model 1 is a saturating curve. A missing file or a missing curve knob means model 0.
Notation: Lv is the victim's modify level, Lc the cast level, svs is -saving_throw (a better save is more negative), shift is a fixed shift in percent.
model 0: save = 40 + (Lv - Lc) * 4 - saving_throw * 90 / max(45, Lv) + shifts, clamped to 5..95, shift = Lv / 5
model 1: P = clamp(base + span * (1 - e^-r) + per_level * (Lv - Lc) + shifts, floor, ceiling)
r = svs / (tau * max(level_floor, Lv))
- Model 1 knobs and their values in the repository:
base40,span50,per_level3,floor5,ceiling90,tau0.45,level_floor45. Every extra point of svs adds a little less than the one before, and the level gap is added outside the curve, so the cast level always counts. - A cursed victim (
saving_throwabove 0) takes the legacy linear term instead of the curve. - Shifts: resistant and berserk add
shift, vulnerable and an alignment-based holy craft roll subtract it. Model 1 shift is5 + Lv / 10. - An immune victim always saves.
- Charm damage compares charisma: the save drops by a number of points per point of the caster's lead, and a player victim with charisma below 18 takes an extra cut. Values are in the
charmblock of the file. - Affects with a handler on the caster can edit the running chance through
onSaves. - Dispel uses its own block (
dispel). Under model 1 the chance isbase + per_level * (affect level - dispel level)clipped tomin..max. A permanent affect at -2 never dispels and one at -1 counts five levels higher. - Chances are rolled in permille under model 1.
Fenia reads the effective model with .savesModel() and the file with .config("fight/saves").
The item-damaging side of fire, cold, shock, acid and poison is scripted, not C++. A source passes a power from 1 to 10. Only fire has a threshold behind that number: the scripts compare the power with the material's burns value in material.json, so straw lights from a match and oak does not. Materials have no freeze or conductivity field, so for cold, shock and poison the power is only a magnitude (chance, duration, damage). Treat a dispute about such a value as a balance question.
The level a spell is cast at is not the caster's level. DefaultSpell::getSpellLevel works as follows. mlevel is the real level plus remort levels. An NPC casts at its level.
Start from mlevel minus a class penalty:
| Profession flag | Penalty |
|---|---|
| caster | mlevel / 20 |
| hybrid | the larger of 2 and mlevel / 16
|
| agile | the larger of 3 and mlevel / 13
|
| anything else | the larger of 5 and mlevel / 10
|
Then, in this order:
- A character with no religion who casts a prayer keeps only
100 - 2 * mlevelpercent of the level (at level 10, 80 percent). At zero or below the prayer is refused. - Shalafi clan members keep half the penalty (the level is raised to the midpoint with
mlevel). - The spell craft skill: on success the level goes back to
mlevel. - Improved maladiction (curses), improved benediction (blessings) and holy remedy (healing and curative spells): on success the level becomes
mlevelplus the skill divided by 20. - Magic concentrate scales ranged offensive non-prayer spells by distance. The closer the target the stronger.
- Mastering spell adds 1 to 4 on success (1 to 5 with the clan upgrade, which also refunds a tenth of the mana).
- The intelligence bonus (wisdom for prayers) from
dreamland_world/fight/int_app.jsonandwis_app.json. - The skill level bonus from equipment and affects.
The result is at least 1. A cast fails at level 0 or below, which happens through the prayer penalty or a negative skill_level_bonus.
A spell's position field is a minimum position, and the aggressor of a fight usually stays standing, so position: stand does not stop casting in combat. To forbid a spell in a fight, check ch.fighting in the spell's pre-run script.
Start here
Get running
Contribute
- Contribute
- Contributing Workflow
- Code Style
- Accessibility
- Writing Player Text
- Game Design Principles
Build areas
- Building
- OLC
- OLC Rooms
- OLC Mobiles
- OLC Objects
- OLC Resets
- OLC Line Editor
- OLC Other Editors
- Area Format
- Area Quests
- Mob Forms and Parts
Fenia
- Fenia
- Fenia Language
- Fenia Triggers
- Fenia Gotchas
- Fenia API
- Fenia Scripts
- Fenia Behaviors
- Fenia Skills and Spells
- Fenia Cookbook
- Fenia Grammar
Engine
- Engine Architecture
- Engine Time and Pulses
- Output Format
- Mechanics Combat
- Mechanics World
- Data Configs
- Item Power
Localization
Clients
More