-
Notifications
You must be signed in to change notification settings - Fork 0
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 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.
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.
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"]
A swing at a spawn-protected target is short-circuited before the pipeline, not just zeroed inside it. Narration is emitted before
dealDamage, so a protected target used to see a phantom "hits you." each round while taking 0 damage.resolveSwingnow bails ahead of the swing when the target is spawn-protected (theguardHarmfulinsidedealDamagestays the authoritative 0-damage enforcement), and the pre-gate also drops a still-protected attacker's own shield on the hostile attempt — matching the attempt-based cancellation every other harm path obeys.
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_hitspec 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
avoidancespecs 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
Weapondice + type (or a mob's natural attack, orunarmed_dice/ a1d3fallback), plus a scopeddamage_bonusformula. Crit is content — acrit_multattribute scales the whole roll through the samedeal_damagepath. -
Mitigation:
raw × resist/vuln/immune − soak, floored at 0. The resist matrix comes from the damage type'sresistmap (1 neutral, <1 resist, >1 vuln, 0 immune);soakis a flat by-type reduction reading the content attributesoak_<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.
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 the routed 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.
Damage routes to a pool. Resolution is three-tier: an op's own resource, else the damage TYPE's target_resource (a damage_type_def may name the track its kind belongs to — psychic → sanity), else the primary vital, so ordinary swings and pre-existing content are unchanged. The type tier is what routes damage a pack did not author — a third-party spell, a natural weapon, a Lua h:damage, and every melee swing, none of which carry a resource. A pack may define several independently-lethal vital pools — depleting any of them kills — and the death test is the per-pool predicate vitalDepleted (def.vital && max > 0 && cur <= 0), applied at both the dealDamage checkpoint and the pool-local cancel re-check. Routing at a pool the target has no capacity for (max <= 0) is natural immunity: discarded before mitigation, reaction, and threat, never written negative. That max > 0 term is load-bearing rather than cosmetic — without it a zero-max pool would read as already depleted and any hit routed there would instantly kill. Non-vital pools are damageable but can never reach die(). The immunity discard keys on whether the blow was ROUTED AT ALL (by either tier), not merely on an op-level resource: an unguarded type-routed blow would write a phantom current on a capacity-less pool, which is a stored one-way door — the pool stops reading as "full when absent", so if it ever gains capacity the entity reads permanently empty on it.
Every pool has consequences; only a vital one has death. When damage empties ANY pool, that pool's on_depleted op-list runs. vital decides only whether death follows: a Sanity/Stress/Stun track bottoms out into an affect or an incapacitation and stops there, and the single edge into die() sits behind one predicate so that is structural rather than a call-site convention. The hook can read the blow's arithmetic — $depletion.overflow / .applied / .amount — so a two-track system can carry a stun track's excess into a lethal pool.
The cancellable death checkpoint: when a vital pool hits 0, that pool's on_depleted runs, then that pool 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. Across pools the death-generation guard is evaluated before the pool-local cancel re-check, so a cascade that depletes two vitals still resolves to exactly one die() — one corpse, one loot roll.
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, noprevents, a non-afflictive category, noonEvent/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 adeal_damage-only blacklist would miss). Removal is hard — noon_expire/OnAffectExpirehooks fire, since a handler could land fresh harm on the just-revived player (the very hole this closes); modifiers and prevents are unwound byrecomputeMods, and comms access is refreshed. A lethal DoT is the subtle case: its own killing tick runs die → respawn → strip inline inside thetickOncesnapshot 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, anOnKill/OnDamageTakenhandler, an affect tick, or a Luaon_deathhook) 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, closed by the actor-agnostic post-respawn spawn-protection window:respawnPlayeropens a short pulse-deadline window (Living.protectedUntil) andguardHarmfulrefuses every harmful op aimed at a protected player — checked ahead of the!isPlayer(target)no-op, so even a mob attacker is covered (the safe-room veto can't be, since mob→player harm short-circuits the PvP gate). The window drops the instant the protected player itself initiates a harmful action, andspawnProtectionPulsesis operator-tunable (0 disables). See Abilities & Effects.
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.deathsis a death generation: a transient, monotonic counter bumped exactly once perdie()(COW-safe viamutableLiving; not carried across a save or handoff).runOpssnapshots 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 sharedeffectCtxrather than a per-frame set, becauseif/chance/checkand 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 firesOnKilland resolves loot before it latchesposDead, and previously had no entry idempotency — so a re-entrantdie()duplicated the corpse, theOnKill, 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.
respawnPlayerclearsposDeadin 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 (aBeforeCastCommitreaction 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.
- 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
lagis 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 nopreventsTag(attacker, "attack")check and noposStunnedposition, so a stunned-but-fighting entity keeps auto-swinging. CC that should stop auto-attacks currently only blocks abilities and movement.
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.)
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.
TelosMUD — Wiki under construction.
- Builder Reference
- Builder Commands
- Trust Tier Model
- Pack Authoring
- Pack MUD Settings
- Pack Lua Scripting
- Pack Lua Hooks
- Pack Entity Reference
- Building Instanced Zones
- Engine Developer Reference
- Architecture Overview
- Entity Component Model
- Zone Runtime & Actor Model
- Instanced Zones
- Command Parser & Targeting
- Edge & Protocol
- GMCP Reference
- Persistence & Durability
- Content Loading & Hot Reload
- Abilities & Effects
- Combat System
- Loot, Spawns & Crafting
- Accounts & Auth Internals
- Orchestration & Directors
- Scoped Event Bus
- Cross-Shard Handoff
- Lua Sandbox Internals
- Distributed Systems Model
- RPC & Protobuf