Every buff and debuff in the game belongs to exactly one ability. Two different abilities stack; one ability never stacks with itself. Magnitudes on the same stat combine additively, never multiplicatively, so a +50% and a +15% is +65% — not ×1.725.
A status is identified by the ability that applied it, not by the stat it moves. Every buff payload a move produces is stamped at build time with:
key = 'ab:' + Move_ID — e.g. ab:M-STORMFATHER-4. This is the identity the engine matches on.name = the ability's own stage name (Stormcall / Call the Storm / Convocation of Storms). This is what the player sees on the status chip.The key is stamped onto every buff clause of the move — its self buff, its ally buff, its single-target ally buff and its Synergy clause — so all clauses of one ability share one identity. Riders, marks and DoTs applied to enemies keep their authored names (Mothervenom, Impale) and are identified by that name.
| Situation | Result |
|---|---|
| Two different abilities buff the same stat | Both statuses exist side by side. Their magnitudes add. |
| Two clauses of the same ability land on one unit | One status. Same key, so they merge — they do not stack. |
| The same ability is cast again | Duration refreshes to the new cast. Magnitude keeps whichever is stronger. Never a second instance. |
"Stronger" means further from the baseline in the direction the payload points: 1.0 for multipliers, 0 for flat adds. A +50% Attack buff re-applied at +35% stays +50%; a −40% Attack debuff re-applied at −25% stays −40%. The chip takes the name of the cast whose magnitude survived, so a weaker later cast cannot relabel a stronger running buff.
This is what caps buff spam. Stoke has Cooldown 1; without same-ability merging a player would reach +50%, +100%, +150% on consecutive turns.
Every status contributing to a stat has its delta summed once, then applied:
atkMult = clamp(1 + Σ(atkMul − 1) × stacks, 0.30, 2.50)
Legacy named statuses that carry no numeric payload contribute fixed deltas: Slagbite and Fracture −22% Defense, Gravechill −18% Attack and −2 Speed, Cinch −3 Speed, Doomrune +18% damage taken, Warded −18% damage taken (the Status_* rows of codex/stat_model.csv, which the game reads; the numbers on this page are filled from that file).
Most statuses are built from a move’s own text (+50% Attack, 3t) and carry that move’s name. A handful of riders are named effects the engine knows by name; this is what each one does to the unit carrying it.
| Status | Family | Effect while it lasts |
|---|---|---|
| Slagbite, Fracture | Defense down | Takes more damage from every hit (the Defense delta above). |
| Scorchrot | Damage over time | Loses 6% of max HP at the end of each of its turns. |
| Necrovenom, Mothervenom, Graveleech | Damage over time | Loses 5% of max HP at the end of each of its turns. |
| Doomrune | Mark | Takes more damage from every source (the damage-taken delta above). |
| Gravechill | Attack down, slower | Hits softer and acts later. |
| Cinch, Slowed | Slower | Acts later in the turn order. |
| Bind | Stun | Skips its action while bound. |
| Warded, Guarded, Hastened, Blessed, Rallied | Buffs | Protective or empowering effects granted by allies; Warded lowers damage taken by the delta above. |
The family of each name is the STATUS_ITYPE map and the per-turn fractions the DOT map, both in src/battle.js, built into play/battle_module.js; they pick the chip icon and the tooltip family too.
A move's Synergy clause is a second buff clause gated on creature type. Two rules govern whether the caster is included:
Because the Synergy buff carries the same ability key as the move's own self buff, a caster who qualifies for both gets one status at the stronger of the two magnitudes. A weak type synergy can never overwrite the ability's own stronger self buff.
M-STORMFATHER-4, Targets=self, Effect_S3 Self +50% Attack, 3t, Synergy Elemental and Dragon allies gain +15% Attack, 3t. Party: Riptide (Elemental·Dragon, the caster), Ignis (Elemental·Dragon, already running its own Feed the Conflagration at +50%), and a Construct character.
| Unit | Statuses after the cast | Attack multiplier |
|---|---|---|
| Riptide (caster) | One: Convocation of Storms, +50%, 3t. The +15% Synergy shares its key and merges at the stronger value. | ×1.50 |
| Ignis | Two: Feed the Conflagration +50% and Convocation of Storms +15%. Different abilities, so both run. | ×1.65 |
| Character (Construct) | None — fails the type gate. | ×1.00 |
The Targets column in moves.csv routes sides and is never inferred from Power. It governs selection as well as resolution:
Targets=self — only the caster is highlighted and only the caster can be clicked.| Function | Responsibility |
|---|---|
buildMove | Stamps the ability key and display name onto every buff payload of the move. |
stKey | Status identity: the ability key when present, else the display name. |
addStatusObj | Finds a status with the same identity and merges, or appends a new one. |
mergeStatus | Refresh duration, keep the stronger magnitude, keep the surviving cast's name. |
statusMult | Additive aggregation of Attack, Defense and Speed with the 0.30–2.50 clamp. |
applySyn | Synergy clause: type gate including the caster, "another ally" test excluding it. |
targetOk / pickTarget | Target legality, shared with the selection highlight. |
Status: on release. D's ruling, 2026-08-12: “Boss buffs / unique mechanics cannot drop. They need to be permanent for the fight and impossible to remove.”
The engine already promised this and did not keep it. A boss's unique rule is
carried as a status with the sig flag: it is painted with the skull icon, and its own
tooltip already reads “This cannot be removed.” But the predicate that answers
“is this a buff?” returns TRUE for a signature status — correctly, it is
a buff — and four separate paths acted on that answer without ever asking whether the thing
was allowed to be taken:
Why it was seen only a few times. Every one of those needs a player carrying a strip, steal or shorten effect. A bare account has none, which is why the report never reproduced and was twice recorded as “could not reproduce”.
The rule now. One predicate — is this buff strippable — is asked at every removal site. A signature status still READS as a buff everywhere else: it shows in the buff row, it satisfies “target has a buff” conditions, and it is described in tooltips exactly as before. It is simply never a legal target for removal, theft or shortening. Authored counterplay is untouched — Sethek's second rising is still cancelled by killing his escort first (#116), because that is the boss's own written rule rather than a status being taken off him.
Engine: isStrippableBuff() and applyFxTo() in
src/battle_round.js, built into play/battle_module.js, whose resolveRound
runs the fight; the SIGFX rules in play/app.js. The applyFxTo still defined
in play/app.js has no caller since 3.7 and is not the live path. Gate:
tools/probe_sigperm.py 15/15, and 8/15 against the build it replaces
— the failing run reproduces all four losses on a real client before fixing any of them, and
every check has a control proving ordinary buffs are still removable.
Thorns comes from two places and both follow one rule: a status on the unit (a move rider such as Quillhide, carrying
thornsPct) and the soul package (the character’s own thorns souls, plus team thorns on every ally).
Each fires once per hit taken and deals its share straight to the attacker’s HP, on the damage line as
“N reflected”. Retaliation ultimates are a different mechanic and are not thorns.
The share is of the damage the unit actually lost on the hit — after its own defence, and never more than it had left. (#771, D ruling A, 2026-09-07 — LIVE on release since 2026-09-08 (0.71).; this retracts the 2026-09-02 rule below.) The 09-02 rule read the hit as the attacker put it out, before the target’s defence, so that a tank reflected as much as a paper target; against an armoured target it did the opposite of its purpose: the floor-108 Talos (Divine, defence 9, its +50% Defense up) sent 15% of a 27k-attack creature’s raw output back — 28,790 — and killed a 3,862-HP attacker outright for landing a 1,722 hit. Under ruling A that same hit returns 258. Both sources, status and soul package; multi-hit summed; overkill counts only what the unit had. Gate tools/probe_thorns.py 5/5 through the real floor-108 boss party and the shared engine.
Retracted rule, kept for the record (LIVE on release 0.66 until 0.71): the share was of the hit as the attacker put it out — the engine computed the hit a second time against its reference defence (10 × STAT_SCALE) and thorns read that figure, so a tank reflected exactly what a paper target would off the same swing.
Before 2026-09-02 thorns read the damage actually taken — the reading ruling A returns to. A tank that mitigated a 2,000 hit to 100 reflected 4% of 100 — the souls were only worth carrying on a unit that took no damage, which is the one unit that never got to use them.
stat_model.csv since hardcode-3 (2026-10-23), so the page now fills them from that file (csv-bind.js). A player-facing table of the named statuses (what Scorchrot, Doomrune, Bind and the rest do) was missing and is added from the engine’s own status map. Stacking, clamps, targeting and the signature rule re-checked against statusMult at dev HEAD: unchanged (0.30–2.50 clamp, additive deltas).applyFxTo in play/app.js, a dead twin with no caller since 3.7; it now names the live src/battle_round.js / play/battle_module.js path. No rule changed.tools/probe_thorns.mjs 2/4 before, 7/7 after (tank reflected 4 where paper reflected 70; now 70/70).Targets=self moves no longer offer allies as targets.