Abilities and Effects

Audience: Engine Developer Status: ✅ Ready

Abilities and effects are the composable substrate content builds every skill, spell, buff, debuff, and status on. The engine supplies primitives — attributes, resources, affects, and an effect-op vocabulary — plus an ability execution lifecycle and an automatic hostility gate. Content names all the flavor; the engine bakes in none of it. Everything here runs on the zone goroutine (single-writer); no op blocks or does DB I/O.

Related: Combat System, Pack Entity Reference and Pack Lua Hooks (the authoring surface), Lua Sandbox Internals.

The generic substrate

Attributes

An attributeDef has a valueKind (int/float/derived — descriptive; derivation is uniform float64), a base formula, min/max clamps, and a stat flag (surfaces in GMCP Char.Stats). The value resolves through a modifier stack:

base (per-entity override, else the def's base formula, else 0)
  → + Σ flatMod  → × Π mulMod  → clamp(min, max)

Results are memoized per entity behind a dirty bit; any base/mod change dirties the whole cache (coarse but correct, since derived-of-derived can ripple anywhere). Two modSources register today: the Affected component (affect mods) and Wearer (worn-gear bonuses). A worn item contributes through the same modSource path whether the bonus is a static modifier on the prototype (armor’s +2 AC — flat summed, mul producted) or a rolled affix, so the two compose; Wearer.mulMod now returns the real mul product (it was previously hardwired to the identity, so armor could add but never multiply). The 5e “sets AC 18, DEX-cap” armor shape stays content: the item contributes a flat armor_base and AC is a derived attribute armor_base + min(dex_mod, max_dex).

The fold itself is bounded, not just the base formula. Two ordinary affects with a large mul modifier compose to +Inf (1e308 × 1e308), so the (base + Σflat) × Πmul fold saturates: ±Inf → ±attrFoldCeiling (1e12 — far above any sane value, far below where a downstream int() wraps) and NaN → the pre-modifier base (“the modifiers cancelled to nonsense, so ignore them”). This runs on the hot derivation path, so a content defect degrades to a large-but-sane, visibly-wrong number rather than aborting a resolution. The screen runs before the min/max clamp deliberately: every comparison against NaN is false, so a NaN slips through a declared [1,5] range untouched — an author who bounded their attribute defensively would otherwise not be protected. (Op formulas that read an attribute were already safe — evalFinite collapses a non-finite result to 0 — so the reachable consumers are the ones reading attr() directly: resourceMax (an infinite max_hp → a bottomless, unkillable pool, the opposite of a one-shot), soak, and any predicate comparing against a NaN.)

The formula evaluator is a prefix-AST over JSON — not Lua, not infix. ["+", ["*", ["attr","con"], 10], ...] means con*10 + .... Heads include + - * / min max clamp floor ceil round mod, the short-circuiting ["if", cond, then, else], ["attr", name] (recursive → derived-of-derived), and ["lit", n]. It fails closed on a NaN/±Inf top-level result, and cycles are caught both by a load-time DFS lint and an eval-time visited set.

Resources

A resourceDef has a maxAttr (a derived attribute that caps the pool), a vital flag (depletion drives death), a primary flag (the default-damage vital), an absorb flag + fronts target (a pre-vital damage buffer — temp HP / a ward — soaked before the pool it fronts; see Combat System), a flat regen, regenInCombat (default false → passive regen pauses while fighting), perRound (a reaction budget topped to max each combat round), gauge (HUD vitals), and an onDepleted op-list that every pool runs, vital or not — on a vital pool it is also the cancellable death hook. An absorb pool declares no maxAttr (its capacity is instance-set — the amount written into it), and a lint flags a mis-declared absorb + maxAttr pool. Max is derived from maxAttr, so anything raising the cap flows through automatically; current is clamped [0, max] on read and reads as full when unstored.

A pack may define multiple independently-lethal vital pools: depleting any one drives death. vital now means only that — “emptying this kills” — because every pool, vital or not, runs its own onDepleted on the damage path. A non-vital pool is therefore a full second track: it can be damaged, it fires its hook at 0, and it still can never reach die(). That is what makes a Sanity/stun/morale track authorable without making it lethal.

Damage reaches a pool through a three-tier routing precedence: an explicit resource on the op wins; otherwise the damage type’s target_resource routes it (so an entire damage family lands on one pool with no per-op annotation anywhere); otherwise it falls to the primary vital, which keeps ordinary swings and all pre-existing content unchanged. primary exists to make that final default explicit — it replaced an implicit “lowest-ref vital def” rule that was deterministic but a footgun.

One guard rail carries a lot of weight: a pool the target has no capacity for (max <= 0) is natural immunity — the hit is discarded before mitigation, reaction, and threat, and the pool is never written negative. That is a structural property, not a check content has to remember: a pack can ship an opt-in pool whose capping attribute defaults to 0, and every pre-existing character and mob is immune to it by construction. See Combat System for the death path and resources / damage_types for the authoring rules the level-triggered hook forces.

Affects

An affectDef carries modifiers ({attr, add|mul, value}, additive scaled by magnitude × stacks), a duration in pulses, prevents tags (CC), a stacking mode, an optional tick, and lifecycle hooks. Stacking modes: refresh (reset duration), stack (increment up to maxStacks), extend (sum durations), ignore (first wins), and highest — several instances of a ref across sources contribute only the strongest, not the sum (5e’s “same-effect doesn’t stack, take the strongest”; instances stay per-(ref, source) keyed, the aggregation just takes the max). Scope defaults to (ref, source); stack_scope: target keys per ref ignoring the applier.

Two more Round-47 duration/potency fields:

  • duration_kind: indefinite — the affect never counts down and ends only via dispel / remove_affect / death, replacing the old “author a huge duration” hack (the demo insane affect’s duration: 400). A -1 remaining sentinel round-trips through save/load. A subtlety the panel caught: an indefinite room field’s per-occupant lease must stay finite (it re-leases while you’re present and lapses after you leave), so the indefinite override does not fire when an explicit positive duration is supplied — otherwise the permanent lease followed the player out of the room and across relog.
  • level — a potency field. dispel now strips the highest-level affect first (5e Dispel Magic), and a per-affect dispel check gate can resolve once per candidate with the potency bound as $affect.level (a contested dispel DC reads 10 + $affect.level); a candidate whose gate resists is left and doesn’t consume the dispel count. The gate fails safe — an unmatched roll resists rather than stripping a buff.

Graded ladders (rungs): an affect whose each rung carries its own non-linear modifiers + prevents (exhaustion rung 4 halves max HP; a madness tier) — something “N × a −1 debuff” can never express. recomputeMods applies the current rung’s set un-scaled; increment_rung / decrement_rung ops move it (increment applies-if-absent, decrement removes below rung 1 — “recover 1 on a long rest”). A highest-wins aggregation over a ladder selects by rung, so a weak rung installed first can’t suppress a severe one.

An affect can also carry two Round-46 SRD fields that recompute alongside prevents:

  • grants_immunity: [charm, poison, …] — an incoming-affect veto. When an affect is applied, applyAffect first intersects the incoming affect’s identity — the union {ref} ∪ {category} ∪ tags — against the target’s unioned immunity set and, on any overlap, vetoes before attach: no attach, no stacking, no on_apply, no recompute (the clean no-op the old attach-then-strip workaround could never give, since OnApplyAffect fires after attach). Immunity is a separate multiset from prevents, deliberately: prevents: [X] means “the bearer can’t do things tagged X”, whereas immunity means “reject incoming affects tagged X” — overloading one namespace would silently couple a silence’s prevents: [cast] to rejecting anything tagged cast. The veto sits after the reattach branch, so a persistence load is never vetoed (a restored set must not depend on affect load order); only a live apply is. A grants_immunity affect has no modifiers/prevents, so the harm gate reads it a buff (warding an ally lands ungated and survives respawn), and a vetoed apply fires OnAffectBlocked so content can narrate.
  • damage_taken_mult — a per-type incoming-damage multiplier the bearer carries (fire ward, vulnerability curse), product-composed like prevents and consulted after soak in the mitigation pipeline. Each factor is normalized at composition (negative → 0/immunity, NaN → 1, over-ceiling → the cap) so a composed value > 1 requires a raw factor > 1 — which the harm gate classifies as harm — closing the “two {fire: -3} buffs compose to a 9× amplifier” cross-player bypass.

The effect-op vocabulary

An op-list is a sequence of {op: <kind>, ...fields} objects. The interpreter runOps walks in order and logs+skips unknown ops (a content lint is the real gate). This is the builder-facing composition surface — the engine-side companion to Pack Lua Hooks.

Op Effect
deal_damage routes the shared dealDamage pipeline — gated + mitigated (below)
heal / restore raise a pool toward max; never gated (only raises)
modify_resource / set_resource signed additive pool write / absolute or take_higher write (the temp-HP idiom); any cross-player write gated
apply_affect attach an affect; gated when harmful/derived-detrimental (buffs on allies ungated)
remove_affect / dispel remove an affect / up to N dispellable of a category (highest-level first); gated on another player
increment_rung / decrement_rung move a graded ladder affect up/down a rung; decrement gated cross-player
act / send perspective message / raw markup to a target; ungated
if / chance branch on a held affect, or a numeric comparison of a LHS (pool current / a formula / a ctx scalar like $depletion.overflow) against a formula RHS (a derived threshold like max_hp/2) with a comparator (>= <= > < == !=); probability branch is deterministic under ctx rng
check a dice-vs-DC or contested roll, classified into the first matching band, whose ops then run
teleport / push same-zone relocation: teleport blinks the target to a room ref / dest: actor (to the caster) / dest: start (the login room); push forces one step along a dir exit. Forcing a player routes guardHarmful (a non-consenting player in a safe/no-PvP room is a clean no-op); moving self or a mob is ungated. Destinations resolve only through this zone’s rooms/exits (structurally same-zone), and an instance entrance is refused (the mint invariant)
grant ops modify_attribute_base, set_flag/clear_flag (refuse reserved trust flags), grant_track/advance_track, grant_ability/revoke_ability, apply_bundle — see Loot, Spawns & Crafting
crafting ops consume_item, produce_item, augment_item (a flat-stat-bump stub), learn_profession, salvage_item, craft_recipe, list_recipes

An indeterminate if operand (a degraded attribute, a divide-by-zero, a non-finite formula) fails the predicate to false (skip the then) rather than collapsing to 0 — matching the boon/bane “a broken channel is no channel” discipline. This is load-bearing: a 0-collapsed threshold on overflow >= max_hp would be always true and fire the harmful branch on a debuffed victim, so failing toward inaction closes that. An unknown comparator, or a comparator with no left-hand side, is rejected at parse (content load fails), not silently coerced.

Damage/heal share amount math: amount + rolled_dice + bonus, all × ctx.mag; the bonus/dice_count formulas evaluate in actor scope by default. The die count can itself be a formula — set it by exactly one of dice / dice_num / dice_count (dice_num is polymorphic: a number is the literal count, a formula routes to the dice_count slot for cantrip-style scaling / spell upcasting), and setting it more than one way is now a parse error rather than a silent drop. An op may also carry fixed: true to opt out of ctx.mag scaling — the flat rider a flame-tongue’s OnHit proc needs, since an OnHit handler’s mag is the triggering blow’s damage (great for proportional lifesteal, wrong for a fixed +1d6 fire). The check op uses the same primitive as combat — scoped $actor./$target./$source. refs, bands testing total / margin / natural face, plus the Round-46 knobs boon/bane (advantage/disadvantage — a net sign selects among boon_dice/bane_dice/dice), a band when predicate (a forcing fifth axis, e.g. auto-crit), and subject: target (the saving-throw idiom — the target rolls and the saver is the event subject) — see Combat System.

The ability lifecycle

An abilityDef names its invocation (command verb / proc / passive), targeting mode + disposition, requires, costs, cast_time/lag/cooldown, the on_resolve op-list + on_resolve_lua, and messages. command abilities bind verbs into the dispatch table after built-ins (never shadowing a core verb).

flowchart TD
    A["1. invoke"] --> B["2. resolve targets (mode)"]
    B --> C["3. checkRequires: cooldown / tag-CC / attr / grant / profession"]
    C --> D["4. outer hostility gate (harmful vs non-consenting player)"]
    D --> E["5. reserve costs"]
    E --> F["6. cast_time: pulse lockout, re-resolve by id"]
    F --> G["7. commit: BeforeCastCommit reaction (counterspell), pay costs, arm cooldown"]
    G --> H["8. on_resolve op-list + on_resolve_lua"]
    H --> I["9. emit actor/room messages"]
    I --> J["10. fire OnAbilityResolved (+ OnSkillUse unless suppressed)"]

Corrections to stale in-code comments: on_resolve_lua and the affect Lua/bus hooks (on_apply_lua/on_expire_lua/on_dispel_lua, on_event_lua) are live, not “reserved Phase 7.” The only still-reserved surface is the op-list form of affect on_apply/on_expire (parsed, logged, not executed).

Not implemented: ability lag is logged only — the round-based WAIT_STATE is a combat-side concern and isn’t imposed (Combat System). proc/passive abilities aren’t registered as verbs; their reactive behavior is the event/reaction bus, not castAbility.

The automatic PvP / hostility gate

Two chokepoints, defense in depth:

  1. Lifecycle step 4 — an outer layer blocking a harmful-disposition ability against a non-consenting player before costs.
  2. In-op guardHarmful — the can’t-bypass inner layer. Every harming op funnels through this one function before touching target state: deal_damagedealDamage; harmful apply_affectapplyDebuff; dispel/remove_affect/modify_resource/grant ops → guardCrossPlayerWrite when writing another player. A new harmful op physically cannot reach a protected player.

guardHarmful returns a clean no-op (never a partial effect) when denied, and fails closed on a detached actor/target (a reaped or mid-transfer entity) so a stale pointer never races an owning goroutine. The policy pvpAllowed is default-deny for player-vs-player: a safe-room flag is an absolute engine veto checked before any Lua policy; a pack-defined pvp_allowed Lua hook decides the genuine PvP case and is fail-closed (it can only be more restrictive); an arena flag forces PvP; otherwise both parties need the pvp consent flag. The harm decision for apply_affect is derived (affectIsDetrimental: any stat-reducing modifier, any prevents tag, or a debuff/affliction/curse/poison/disease category) OR’d with the explicit label — an author can force-gate but can never un-gate a genuine debuff. Crucially the classifier (and the respawn-strip predicate) fold in a ladder’s rung modifiers/prevents, not just the top-level ones: a rung-only ladder whose top level is empty would otherwise read benign and land on a non-consenting player ungated — the sixth harm-classifier blind spot the SRD rounds surfaced. For the same reason decrement_rung is gated cross-player (weakening another player’s ladder is harm the engine can’t prove benign).

A player also counts as protected for a brief post-respawn window: respawnPlayer opens a pulse-deadline protectedUntil, and guardHarmful refuses every harmful op aimed at that player — checked ahead of the !isPlayer(target) no-op, so even a mob actor (whose harm short-circuits pvpAllowed before the safe-room veto ever runs) is covered. The window is actor-agnostic and drops the instant the protected player itself initiates a harmful op — the same cancel discipline as a hostile-action cooldown, applied uniformly across dealDamage/applyDebuff and a non-self guardCrossPlayerWrite; spawnProtectionPulses is operator-tunable (0 disables). This is what closes the respawn-then-harm gap that stripping affects at respawn cannot: harm from a separate later call by a mob, which no PvP or safe-room check would gate.

The one path that looked exempt: a sourceless ambient hazard. A room field with no applier (lava, gas) ticks with actor == target, which guardHarmful normally treats as an exempt self-effect — so the field kept damaging a just-respawned occupant inside the window. A sourcelessAmbient flag, set only where the sourceless fallback collapses the actor onto the occupant, makes the window’s enforcement fire even at actor == target, while the cancellation stays gated on actor != target so the field never drops the occupant’s own shield. The flag is threaded into all four such sites the review surfaced: the room tick’s damage, its modify_resource/dispel/remove_affect writes (previously self-exempt), the room CC/prevents lease, and an entity-scoped DoT.

The gate is re-evaluated per op, per AoE target, per DoT tick, and inside Lua on_resolve — the disposition flows into the effect context at commit and into every tick context.

Death inside an op-list

Death is uniform: any deal_damage can run the entire death funnel inline — corpse, loot, and (for a player) respawn — and return to the next op in your list. Two rules follow, and both are enforced by the engine, not by convention:

  • An op is skipped if an earlier op in the same cascade killed its target. [deal_damage <lethal>, apply_affect rooted] does not root the player who just respawned at the temple. The skip is per-target and identity-keyed, so a rider aimed at someone else — an AoE’s other victims, a self-buff — still runs. It spans nested if / chance / check branches and the area loop.
  • The whole list stops if an op killed the ACTOR. A thorns proc or a reflected nuke that fells the caster on op 1 means ops 2..N never run. This is content-visible: a multi-strike [deal_damage, deal_damage, apply_affect] “flurry” whose caster dies to thorns on strike 1 lands only strike 1. Nothing is pre-rolled — each op rolls when it runs — so aborting is the coherent choice.

Do not try to detect a death yourself by comparing the target’s position or hit points. A player slain in the start room respawns in place on full hit points with posDead already cleared, so both signals read “nothing happened”.

Death narration belongs in on_depleted or an OnKill handler, not in the ops after a lethal one — those are skipped by design.

Tag-based crowd control

CC is open-string tags; the engine hardcodes no “rooted”/”silenced”. An affect’s prevents tags union into the Affected component’s multiset (count per tag, so overlapping affects remove cleanly). At lifecycle step 3, an ability’s own tags (plus requires.not_prevented) are checked against that set: a silence affect declares prevents: [cast], and every ability tagged cast is blocked — the engine never names the CC type.

Two conventional tags carry engine-side meaning by content convention, not hardcoding:

  • act gates auto-swings (swingGatesPass refuses an attacker whose prevents set carries act), so a stunned or downed attacker can’t keep swinging — see Combat System.
  • react gates reactions: every reaction checkpoint checks canReact(e) = canAct(e) && !preventsTag(e, "react"), so an incapacitated reactor takes no out-of-turn action (no Shield, no opportunity attack). This gates only the reaction checkpoints — a stunned creature still takes DoT ticks and expires affects — so content chooses on_reaction_lua (gated) vs on_event (ungated) to draw the reactive-vs-passive line.

Source-relative CC (prevents_source): a tag that blocks an action only when its target is the affect’s own source. Charmed is prevents_source: [attack] — the bearer can’t attack the charmer specifically but attacks everyone else, which a target-less prevents could never express. The runtime keys these per (tag, source), consulted by the melee swing gate and the single-target cast gate. Scope, stated honestly: this is a targeting gate on cast + swing, not a “cannot harm the source” firewall — an AoE that catches the source, a reaction, a Lua h:damage, and a pre-existing DoT all bypass it (each still independently PvP-gated), matching 5e “can’t target the charmer.” It is fail-open across save/load/handoff (the source pointer isn’t persisted — the property every source-keyed affect behaviour shares).

Concentration (concentration: true) enforces a single slot per source, wherever the affect lives (a remote charmed enemy, a room field, the caster). Applying a new concentration affect auto-expires the source’s prior one (firing its on_expire so an anchored effect tears down); the source’s incapacitation (prevents: [act] or the downed state), the death of either caster or target, or the affect’s own expiry frees the slot; the break-on-damaged-save half is clean content (an OnDamageTaken reaction that rx:cancel()s). It is non-durable by design (the source isn’t persisted, so a reloaded concentration is live but unslotted until re-cast). The load-bearing correctness point is single-writer safety across zones: expireConcentration is gated on holder.zone == z, and the transfer/quit seams break a mover’s concentration before it detaches (mirroring combat’s disengage-on-transfer) — so the origin zone can never mutate a holder another goroutine now owns.

Affect runtime

The Affected component is the entity’s single affect modifier source (registered once, not one per affect — that was a leak class). Apply keys by scope, applies the stacking rule, recomputes the summed mod maps, and fires on_apply. A per-entity tick fires every pulse: it runs tickOps through the gated interpreter (a DoT on a protected player is PvP-gated exactly like a direct spell, and kills through the uniform death seam), decrements remaining, and expires at 0. The state-injection boundary (persistence/handoff) only lets plain data cross — a function or handle is rejected at save, never persisted — see Lua Sandbox Internals. Room-scoped affects attach to the room and lease per-occupant instances renewed each tick, so “in a web” is never special-cased.