-
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).
On entering a fight, startFight fires an engine-owned OnEnterCombat event about each entity that just joined (its opponent as other), so a content check can roll initiative into combat_order before the first round sorts by it — the roll draws from the seeded combatRng so a replay reproduces initiative order. It fires per entry, not per fight (a fled-and-re-engaged combatant re-fires), so content owns handler idempotency. Then 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.
A profile can instead declare a multiattack routine — an ordered list of different attacks (a bite 1d10 + two claws 1d6), each with its own dice/type/count. The swing loop cycles the routine, resolving sum(count) swings that each use their entry's dice instead of the wielded weapon — a monster's heterogeneous Multiattack (the martial same-weapon Extra-Attack case is already the homogeneous attacks-count path, unchanged). A routine replaces attacks rather than multiplying it, all entries share the profile's one to_hit, and the total is capped at maxSwingsPerRound; every routine swing funnels the same dealDamage path (gated, mitigated, absorb, crit, threat, death checkpoint) as a weapon swing, so there's no double-count and each swing is independently PvP-gated. A malformed routine dice spec marks the profile broken (refused on both paths).
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.
Three content knobs shape a check while the engine learns no new vocabulary — each was a Round-46 SRD primitive:
-
Boon / bane (advantage/disadvantage): a check may carry
boonandbanescoped formulas whose net sign selects which of three content-written expressions is rolled —boon_dice,bane_dice, or the neutraldice. The engine deliberately does not synthesize "roll twice, keep higher": that presumes it knows which direction is better, and it doesn't — the demo's roll-under avoidance ladder (1d100 succeeding below$actor.dodge) would be inverted by keep-high. Selecting among expressions the author wrote also lets a dice-pool boon add a pool die (2d6>=4→3d6>=4), and netting by sign (not magnitude) makes 5e's cancellation rule — any advantage + any disadvantage is a straight roll, however many of each — fall out for free. Becauseboon/baneare ordinary attribute formulas they compose through every existing modifier source (affects, affixes, racial bases) with no new per-entity state and no DTO field: an affect grants advantage withmodifiers: [{attr: atk_boon, op: add, value: 1}]. -
when— a forcing band predicate: a band may carry awhenscoped formula, ANDed withmin/max/margin_*/face_eqas a fifth axis. A when-only band firing on$target.helplessis how content authors auto-crit / auto-fail-save — with no engine "crit"/"fail" vocabulary and no band-index or label matching. Truthiness is non-zero and finite (the finiteness half is load-bearing:NaN != 0is true in Go, so a naive predicate would fire on a NaN). It replaced an affine sentinel-edge hack that mis-fired the moment two sources set the flag. -
subject— who rolls: defaults to the ctxactor;subject: targetis the saving-throw idiom — the ctx target rolls, bare refs default to the target, the roll narrates to the saver, andOnCheckfires with the saver as subject (the forcing caster rides as the eventother), which is what makes "on a successful save, the saver gains X" authorable. Explicit$actor/$target/$sourcestill bind the fixed ctx entities, andsubject: targetwith a contestedvsis rejected. One authoring trap:subjectdoes not rebind the band ops' target vocabulary — the default op target follows the saver, buttgt: self/$actoris still the caster. -
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, via two composable knobs:crit_multscales the whole roll (the PF/WoW "double the total"), whilecrit_dicedoubles only the dice term (1d8+3→2d8+3— the 5e rule and the correct extra-dice variance; the flat amount and scoped bonus are added once). Both default inert, compose when both are set, and reach both crit paths — a swing crit and a check-band crit (so spell crits, previously unwired, share the mechanism). The dice multiplier uses set-not-multiply semantics and is saved/restored around each op, so nested crit bands don't compound and it never leaks into a sibling op, a later swing, or a DoT tick. -
Mitigation:
(raw × globalMatrix − soak) × perTargetMult, floored at 0. The global 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. The per-target multiplier is adamage_taken_multmap an affect grants the bearer (a fire-immune ward, a vulnerability curse), consulted after soak. It is product-composed likeprevents, so absence is identity-1 structurally (a map miss) — avoiding the attribute-namespace pitfall where an absent value reads 0. Each factor is normalized at composition (a negative → 0/immunity, a NaN → 1, over-ceiling → the cap), which is what stops two{fire: -3}"buff" affects — each a benign buff to the harm gate — from composing to+9and amplifying the victim's fire damage: a composed value> 1now requires a raw factor> 1, which the PvP gate classifies as harm and refuses cross-player.
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 → absorb buffer
→ 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.
An item proc is just an equip-affect that subscribes OnHit — a flame-tongue is a weapon whose equip_affects affect handles OnHit and deals a rider, needing no item-subscription machinery in the bus. Two guards make that safe: an OnHit self-loop guard (an OnHit proc's own deal_damage would re-fire OnHit and recurse — so deal_damage skips re-firing OnHit when the damage source is the attacker whose OnHit is on the stack, while a different source in the cascade, e.g. a victim's thorns reflect, still procs its own; OnDamageTaken always fires), and the fixed op flag so a flat rider doesn't scale with the blow.
The absorb buffer (temp HP / wards) sits between the OnDamageTaken reaction and the vital write. A resource flagged absorb and fronting a pool (default the primary vital) soaks the mitigated blow — all damage types — and spills only the remainder to the vital; a fully-absorbed blow reaches the vital as 0 (a no-op there, like full mitigation — it fires the reaction, not the bus, and builds no threat). An absent or empty buffer is simply skipped, so having no temp HP never confers immunity. An absorb pool declares no max_attr — its capacity is the amount written into it (rolled, instance-set), which is why it needs the new set_resource op (mode: take_higher for "roll a new ward, keep the higher", or an absolute set), since modify_resource is strictly additive.
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.
A third disposition: hold at 0 (downed/dying). Between the vital re-check and die(), onPoolDepleted looks for an active affect carrying suspends_death; if one is present it holds the victim at 0 — pool empty, posDead unset, no corpse, no respawn — instead of dying. die() keeps its single call site (so "a non-vital depletion can never kill" stays structural), and the on_depleted hook already runs before this check, so content's HP hook can apply_affect a dying affect onto itself and the re-check sees it — no new op. The engine provides only three guarantees around the hold: it skips die(), regen skips a death-suspended entity (or a downed creature would heal itself back up with nobody acting; regen resumes the instant the suspension lifts — a stabilized creature recovers), and a downed entity can't swing (below). Everything else is a content affect — the dying affect's finite duration, its on_tick death-save loop, and its prevents tags drive the resolution: true lethal damage removes the affect so the next blow kills normally, a heal above 0 revives, or expiry + resumed regen recovers. This is deliberately the small primitive: no Position enum (which would touch ~24 comparison sites in the death funnel) and no content-replaceable respawnPlayer (whose 7 security invariants a re-implementation would forget).
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) — including the walk path (attemptMove), not justflee. That was a confirmed bug on main:attemptMovenever consultedprevents: move, so a rooted/webbed/grappled player walked out freely, silently voiding the entire immobilize half of the affect system. The check now sits before the auto-stand, exit resolution, thetraversehook, and the instance-entrance branch — a held player is never stood up, never fires a move-hook, and can't mint a dungeon for a move that can't happen.
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.- Auto-swings are gated by
prevents: [act]but not by a finer per-tag "attack" gate.swingGatesPassnow refuses an attacker whosepreventsset carriesact— so a stunned, paralyzed, or downed attacker (whosedyingaffect preventsact) cannot keep swinging even if a retaliation dragged it into afightinglink. There is still no per-ability-stylepreventsTag(attacker, "attack")check and noposStunnedposition, so CC that should stop attacks specifically (while leaving other action) must use theacttag.
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.)
Incapacitation gates every reaction. A reaction is an action taken out of turn, so an incapacitated reactor takes none: every reaction checkpoint —
ToHit(Shield),BeforeCastCommit(Counterspell),OnDamageTaken, and the opportunity attack onOnLeaveRoom— now checkscanReact(e) = canAct(e) && !preventsTag(e, "react"), so a stunned defender no longer Shields and a paralyzed foe makes no opportunity attacks. Thereacttag is the conventional CC tag an incapacitating affect lists in itspreventsset — the engine names no condition. This gates only the reaction checkpoints, never the plain event bus: a stunned creature still takes DoT ticks, still levels, still has affects expire. Content draws the passive-vs-reactive line by choosingon_reaction_lua(gated) overon_event(ungated) — a passive thorns shield keeps firing while stunned; a reactive Shield does not.
Combat numbers are overridable through the check-spec / formula / attribute substrate (combat_profile_defs incl. the multiattack routine, band labels, crit_mult / crit_dice, 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