Skip to content

Combat System

Kurt edited this page Jul 9, 2026 · 11 revisions

Combat System

Audience: Engine Developer Status: ✅ Ready

Combat is round-based in the Diku/ROM tradition, driven off the zone pulse. A fight arms one per-zone driver that resolves a round every ~2.5 seconds; each swing runs a layered pipeline — to-hit, avoidance, damage, mitigation — where every number is content-supplied through the check/formula/attribute substrate. The engine names no class, weapon, or condition. All rolls come from a per-zone seeded RNG, so a fight is reproducible when seeded.

Related: Abilities & Effects (the shared damage funnel + reaction checkpoints), Entity Component Model.

The round / heartbeat fight loop

The pulse quantum is 250 ms; a combat round is PULSE_VIOLENCE = 10 pulses (~2.5 s). startFight sets both parties to posFighting, sets the fighting pointers (forking a proto-aliased mob via COW first), makes an unengaged target auto-retaliate, and arms one every(PULSE_VIOLENCE) driver per zone (idempotent; self-retires when no live fighters remain).

Each round: gather combatants (players by sorted character id for determinism; mobs by scanning the rooms where a fighting player stands), order them by a stable sort on the content attribute combat_order (the initiative seam — 5e initiative is optional content), top up per_round reaction budgets, then resolve swings per attacker, re-validating the fighting link each iteration. Attacks per round = the attacks attribute (floor 1, hard cap 50 so a haste stack can't spin the goroutine). A killing swing stops the rest of the round.

kill enters combat; flee leaves it (flee <dir> is the ROM panic-flee — it fires the OnLeaveRoom opportunity-attack checkpoint while still engaged). Combat is transient — never persisted, never handed off; a move/transfer calls disengage first, and no fighting pointer ever crosses a zone.

Reproducible RNG

Zone.combatRand is a per-zone *rand.Rand (production-seeded, reseeded to a fixed value for tests/replay). It is the single draw source for to-hit, avoidance, crit, damage dice, in-combat ability rolls, and DoT tick damage — so a fight never touches process-global math/rand and replays deterministically when seeded.

The single-swing pipeline

flowchart LR
    G["gates<br/>(canAct/canDefend/same-room)"] --> R1["ToHit reaction<br/>(rx: modify 'ac')"]
    R1 --> TH["to-hit check<br/>(combat_profile.to_hit)"]
    TH --> AV["avoidance ladder<br/>(first success negates)"]
    AV --> D["damage (+crit)<br/>weapon dice + bonus"]
    D --> M["mitigate<br/>resist × − soak"]
    M --> A["apply → OnHit"]
Loading

The check primitive is the universal roll for attacks, saves, skill checks, and contested rolls: roll a content dice spec, add a scoped bonus formula, compare against a dc formula (or a contested defender roll), and classify into the first matching ordered band. Bands test the total, the margin over DC, and/or natural faces (the only way to author a nat-20 crit / nat-1 fumble). Roll visibility defaults to hidden; staff rolls on upgrades engine-default-hidden checks to full math.

  • To-hit: the attacker's combat_profile.to_hit spec resolves; the band label classifies (miss/fumble → miss, crit → crit, else hit). A nil profile auto-hits — the degenerate bare-engine case.
  • Avoidance: the defender's ordered avoidance specs run; the first success negates the swing. The engine hardcodes no dodge→parry→block order — it runs whatever content authored (5e/WoW author none).
  • Damage: wielded Weapon dice + type (or a mob's natural attack, or unarmed_dice / a 1d3 fallback), plus a scoped damage_bonus formula. Crit is content — a crit_mult attribute scales the whole roll through the same deal_damage path.
  • Mitigation: raw × resist/vuln/immune − soak, floored at 0. The resist matrix comes from the damage type's resist map (1 neutral, <1 resist, >1 vuln, 0 immune); soak is a flat by-type reduction reading the content attribute soak_<type> fed by armor.

combat_profile_defs drive all three (to_hit, avoidance[], damage_bonus); a malformed sub-spec logs loudly and degrades (auto-hit / no avoidance), never aborts boot.

Damage application & death

Every damage source — melee, spell, AoE, DoT tick, opportunity attack — routes through the one shared funnel dealDamage:

guardHarmful (PvP/detached gate) → mitigate → OnDamageTaken reaction
  → subtract from vital pool → threat → OnDamageTaken event → OnHit event → depletion checkpoint

guardHarmful is the single hostility chokepoint (Abilities & Effects). Threat accrues before the death scrub so a killing blow is still attributed.

The cancellable death checkpoint: when the vital pool hits 0, the resource's content on_depleted op-list runs, then the vital is re-read — if a hook revived the victim above 0 (a death-ward modify_resource hp +1, a second wind), the death is cancelled declaratively: no die(), no corpse, no respawn. The re-check is the cancel mechanism; the death hook is recursion-bounded so a ping-pong on_depleted terminates rather than overflowing the stack.

die order: emit the death line → fire OnKill while the victim is still in-room → resolve loot per eligible looter → disengage + scrub threat → latch posDead → fire the Lua death trigger → player respawns / mob becomes a corpse. A corpse is an engine-built container holding all carried + worn items, stamped with a 60 s loot-ownership window keyed by the killer's durable id (anti-ninja-loot); a mob-on-mob kill is free-for-all. Threat is a per-Living map; topThreat picks the highest live in-room foe (deterministic tie-break by name).

Player death is minimal: a full-heal respawn at the start room — no corpse, XP loss, or gear drop. That's a deferred ruleset knob.

Skill lag, cooldowns, afflictions

  • Cast time and cooldown are implemented (cooldown persists as remaining pulses across logout and rehydrates on load).
  • Afflictions/DoTs tick through the affect runtime, drawing from combatRng() and routing the gated funnel, so a DoT on a protected player is PvP-gated and kills through the uniform death seam.
  • Tag-based CC (prevents) blocks abilities and movement (Abilities & Effects).

Two honest gaps worth flagging:

  • Skill lag / WAIT_STATE is logged only, not enforced. An ability's lag is parsed and logged at commit but imposes no timer — the single biggest combat gap.
  • Basic auto-swings are not gated by prevents. The swing gate checks only position (sleeping/dead); there is no preventsTag(attacker, "attack") check and no posStunned position, so a stunned-but-fighting entity keeps auto-swinging. CC that should stop auto-attacks currently only blocks abilities and movement.

Reaction checkpoints (combat side)

Result-altering Lua hooks at named checkpoints, with a closed per-checkpoint field allowlist (a non-allowlisted rx:modify is a silent no-op):

Checkpoint Fires rx may
ToHit defender, before the to-hit roll modify("ac", …) only — threaded into the to-hit DC for this swing only, never persisted
OnDamageTaken the damage-taker, after mitigation, before apply modify("amount", …) reduce-only, cancel(), replace_target(handle) (redirects the raw blow, re-mitigated against the new target's resistances)

Both thread the round-shared event budget so a reaction cascade can't blow the heartbeat. (BeforeCastCommit — a counterspell cancelling a cast — is the ability path, not a swing; see Abilities & Effects.)

Formulas: content vs engine

Combat numbers are overridable through the check-spec / formula / attribute substrate (combat_profile_defs, band labels, crit_mult, attacks, combat_order, soak_<type>, damage-type resist, unarmed_dice, xp_value) — this is the live mechanism. The separate named-formula Lua registry (to_hit/soak/regen/xp_for) is a mostly-dead seam: only regen is wired today; a to_hit/soak/xp_for override parses, warns at load, and is never called. Also reserved: a per-pack PULSE_VIOLENCE override, and GMCP per-stage combat / cooldown / lag HUD deltas.

Clone this wiki locally