Skip to content

Combat System

Kurt edited this page Jul 17, 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.

No hostile effect survives respawn. respawnPlayer — the one chokepoint every player death funnels through — purges every affect the victim carried into death that isn't provably benign, before the vital restore. The predicate is a whitelist inversion: an affect survives only if it has no stat-reducing modifier, no prevents, a non-afflictive category, no onEvent/reaction proc, and only benign tick ops (heal/restore/act/send). "Hostile" is open-ended and unenumerable, so "provably benign" is the closed, checkable set and an unknown or future op fails toward stripping (a can't-forget default that catches resource drains, flow-wrapped or save-gated DoTs, and procs a deal_damage-only blacklist would miss). Removal is hard — no on_expire/OnAffectExpire hooks fire, since a handler could land fresh harm on the just-revived player (the very hole this closes); modifiers and prevents are unwound by recomputeMods, and comms access is refreshed. A lethal DoT is the subtle case: its own killing tick runs die → respawn → strip inline inside the tickOnce snapshot loop, so a re-entrancy membership check skips any stripped snapshot entry and no later hostile tick lands post-respawn. This is the durable form of the invariant the #69 cross-respawn guard enforced only for the data op-list path — it now holds no matter which death path (op-list, an OnKill/OnDamageTaken handler, an affect tick, or a Lua on_death hook) applied the affect. Scope: it strips affects present at death; harm a separate later call applies to the revived, living player is ordinary gated harm, addressed by an actor-agnostic spawn-protection window (deferred — the safe-room veto does not cover it, since mob→player harm short-circuits the PvP gate).

The death generation

Because death is uniform, a single deal_damage runs the entire funnel inline — corpse, loot, and (for a player) respawn — and then returns to the next op in the caller's list. Two engine guards make that safe:

  • Living.deaths is a death generation: a transient, monotonic counter bumped exactly once per die() (COW-safe via mutableLiving; not carried across a save or handoff). runOps snapshots each op's bound target's generation around the op, so a target that died has its later ops in the cascade skipped, and if the actor died the list stops. The dead-set lives on the shared effectCtx rather than a per-frame set, because if/chance/check and the area loop recurse on the same context and rebind the target — a per-frame set couldn't see a nested frame kill someone. The builder-facing consequences are on Death inside an op-list.
  • die() takes an entry re-entrancy latch. It fires OnKill and resolves loot before it latches posDead, and previously had no entry idempotency — so a re-entrant die() duplicated the corpse, the OnKill, and the loot roll: an item dupe. The entry latch, plus a post-hook generation re-check, closes it.

Position cannot carry the "did it die?" signal. respawnPlayer clears posDead in the same call stack, and only moves the victim when it isn't already standing in the start room — so a player slain in the start room respawns in place, and both position and location read "nothing happened." That blind spot was live in two shipped guards (a BeforeCastCommit reaction that killed the caster, and an opportunity attack that killed the mover); both now compare the death generation. The location check stays, because it also catches a non-lethal forced relocation.

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