Damage System
This page covers the whole thread in one place: how to write it in a datapack (declaring a hit's damage type and element, and which dealing paths are folded into the pipeline) and why it is built the way it is in the code.
Where the code lives
| Class | Responsibility |
|---|---|
runtime.damage.DamageCalculationService | The one public exit: computes both layers, builds the damage source, applies the hit. |
runtime.damage.DamageElements | The reverse index behind "what is this strike made of": damage type → the elements that claim it. |
runtime.damage.DamageEventBridge | Where the reduction layer is attached, on LivingIncomingDamageEvent. |
runtime.element.ElementReactionService | Element buildup and reactions, also fed by that same event. |
The package prefix is com.iafenvoy.mxt; every file is under src/main/java/com/iafenvoy/mxt/.
The timeline of one strike
Layer one has exactly one entry point, deal, so no path this mod deals damage through can miss it. Layer two has exactly one entry point, the event, so every hit that reaches an entity — a vanilla fall, another mod's sword, our own fireball — passes through the same reduction.
Why it has to be two layers
Both layers read the same answer to "what is this strike made of", but they compute it next to two different entities. That is forced by the data, not a matter of taste:
- Shaping needs information only the dealing side has: which ability is being cast, what progression level multiplier this casting carries, what the attacker's spirit roots are, and who the target is. The defender-side event sees a
DamageSourceand a number, and cannot reconstruct any of that. - Reduction needs information only the receiving side has: the target's own adaptation relations, and every hit that arrives — including vanilla's and other mods', which never go through
dealat all. Putting it at the dealing side would make it apply to our own attacks only.
The cost is that the two must not overlap: DamageEventBridge therefore lives on an event that fires exactly once per damage sequence, and no dealing path applies reduction on its own.
Layer one: shaping
public static double outgoing(@Nullable Entity attacker, Entity target, double amount,
@Nullable FormulaContext context, Set<Holder<Element>> elements) {
if (!Double.isFinite(amount) || amount <= 0.0D) return 0.0D;
double pair = attacker == null ? 1.0D : physiqueMultiplier(attacker, true) * overcomeMultiplier(elements, target);
double result = amount * masteryMultiplier(context) * elementMultiplier(context)
* selfConflictMultiplier(attacker) * pair;
return Double.isFinite(result) && result > 0.0D ? result : 0.0D;
}The order of operations is fixed: the base value from the pack or a formula → × damage_multiplier → × element_modifier → × the self-conflict factor (conflict_multiplier) → × the attacker's damage_dealt_multiplier → × the attacker's overcomes. The last two are the pair above: with nobody credited it is 1.0 outright (see below). The target's adapted_to and damage_taken_multiplier only enter afterwards, in layer two.
The diagram below lays that fixed order out end to end and marks where the pack's own multiplications stop and the pipeline's begin:
damage_multiplierbelongs to the casting. Casting an ability writes it into the formula context, and the value is read at runtime from the level the holder currently stands on: it walks every progression the holder carries, keeps the ones whose current level actually grants this ability, and takes the largest multiplier among them — several owners do not stack their multipliers, because only one strike is being dealt. Aprogressiondefaultsdamage_multiplierto1.0, and a negative or non-finite value is rejected while loading.element_modifieris the spirit-root half, and it belongs to the casting as well. The samewithAbilityScalingfolds "theelement_ability_modifierof the matching roots" into one value throughelement_affinity_mode(average or best) and writes it into the context, andelementMultiplier(context)multiplies it straight in. It is not only a variable for formulas to read — whether content writes"damage": 12or"damage": "12 * element_modifier", the latter now multiplies twice, so do not write it by hand again. When the ability carries noelement_affinity, or the damage does not come from a casting (a formation tick, a curse, a vanilla attack), the context has no such value and it reads1.0; a root that writes this multiplier as0means "this element of mine deals nothing", and this layer still multiplies by0(the same reading the casting gate uses).damage_dealt_multiplier/damage_taken_multiplierbelong to the person. They come from activephysiquedefinitions: layer one reads the attacker's "dealt" multiplier and layer two reads the target's "taken" one, and several active physiques multiply (each one is an independent source). They are evaluated in the holder's own formula context rather than the other side's — how much this body takes cannot depend on who is asking. A literal number is validated as finite and non-negative while loading, and a negative or non-finite value produced by a formula counts as "no contribution" (the same rule, for the same class of formula, as passive attributes);0is legal and means immunity, or that nothing can be dealt.conflict_multiplierbelongs to the element being wielded: when an active spirit root of the attacker lists the element of their main-hand item inconflicting_elements, that element's ownconflict_multiplier(default1.0) is multiplied in. The item's element is read the way weapon_binding describes it — the stack's ownmxt:elementcomponent unioned with theelementa definition declares, falling back to theaura_typeof the aura the stack carries only when neither exists. It is applied once per element however many roots conflict with it (two conflicting roots would otherwise square it), and it deliberately does not require the strike to be made of that element: fighting your own weapon weakens whatever you channel through it, which is the point of the rule. A pack that never writes the field is never affected by it.- The element factor belongs to both sides.
overcomeMultiplierruns a double loop over the attacking and defending element sets, multiplying every pair, and each pair goes throughElement#overcomeMultiplier, which multiplies every matching relation in that element'sovercomes. An empty set on either side (no spirit roots, no elements), or a target that is not aLivingEntity, reads as1.0— "there is no relation to apply". - Backlash and self-damage read no element edge, but they keep "what this casting is worth". When
mxt:damagelands on the caster themself the attacker isnull(DamageAction#attackerreturnsnullas soon ascaster == entity), so both of the last two factors inoutgoingread1.0: no relation is applied and the "dealt" multiplier has nothing to be read from either (there is no attacker). That holds even when the strike carries an element of its own through a declareddamage_type/element— a relation is a relation between two entities, and one entity is not a pair.damage_multiplierandelement_modifierstill apply: the stronger the technique and the better the root fits, the heavier the backlash, because that is the worth of the casting, not a property of the strike. Layer two still runs as well, because adaptation anddamage_taken_multiplierare properties of the body that takes the hit. - Nothing is clamped. Anything non-finite or ≤ 0 counts as "no damage"; otherwise the number is returned as computed. Numeric discipline is the pack's job (a multiplier must be finite and non-negative, but
0.0is legal and means "no damage").
Layer two: reduction
public static double incoming(LivingEntity target, Set<Holder<Element>> attacking, double amount) {
if (!Double.isFinite(amount) || amount <= 0.0D) return 0.0D;
double result = amount * adaptationMultiplier(target, attacking) * physiqueMultiplier(target, false);
return Double.isFinite(result) && result > 0.0D ? result : 0.0D;
}- Only the target's
adapted_tois read.adaptationMultiplieralso multiplies pair by pair, but takes only the defending side's relations. Being overcome is the attacker's advantage, not a second bonus for the defender, soovercomesdoes not take part in this layer. - The target's own
damage_taken_multiplieris read here too, andphysiqueMultiplierlooks only at the physique definitions rather than at who is attacking: a layer of physical reduction, a layer of attribute resistance or a one-off "unyielding body" all sit on the same table and behave the same whoever deals the hit. It likewise has no special case between1.0and0.0—0.0is immunity. - It runs once.
LivingIncomingDamageEventfires exactly once per damage sequence, whoever dealt the hit — that is what keeps this layer from being applied twice. - This layer never cancels. Refusing a hit belongs to the protection and invulnerability rules (
FormationProtection, vanilla resistances); a relation only changes what the hit is worth. The listener therefore starts with "if it is already cancelled, do nothing": a cancelled sequence applies nothing, so rewriting its amount would only rewrite a number nobody reads. - A hit dropped by the invulnerability timer still goes through this layer. Vanilla discards a repeated small hit after the event fires, so "no health lost" does not mean "nothing was computed" — the reduced amount goes nowhere, but the element has already been built up on the target (next section).
- Clients are skipped outright (
target.level().isClientSide()), so a client never treats a server-only registry as authoritative.
What this strike is made of
An element is read only from the damage type, and that is the foundation of the whole design: layer two is handed nothing but a DamageSource, so if the element could not be looked up from typeHolder(), there would be no way to reduce by it.
DamageElements keeps a reverse index: Map<damage-type registry instance, Map<Holder<DamageType>, List<Holder<Element>>>>.
- Claims come from an element's
damage_typesfield, either a concrete type or a tag (a tag expands to every matching type in the registry). When several elements claim one type, all of them are kept and every claim multiplies — the same rule several spirit roots already follow — and a warning is logged, because that is usually a pack mistake. - The index lives as long as the registry instance, and is dropped wholesale on every data pack load. It is keyed on the damage type registry instance and
ServerCacheexplicitly callsinvalidate()when a pack loads, so it never outlives the pack that built it; at most four registries are cached, and the whole cache is rebuilt once it overflows. The index is built from thedamage_typeseach element in the registry declares, so a change to what an element claims takes a data pack load to show up in the reverse index. - Fallback order: a claimed damage type uses its claimants; an unclaimed one falls back to the attacker's spirit-root elements; with no attacker either (a fall, a cactus, unattributed environmental damage) the set is empty, both layers read
1.0, and themxt:elementcondition returnsfalse. That is how "a strike nobody can classify has no element" is implemented — rather than inventing a default element. Layer one also has a rule that is independent of the element set: with nobody credited,overcomesstays out even when a declared type gives the strike an element (previous section). - The origin also decides whether anything is left behind.
DamageElementshands out the elements and their origin together (Strike(elements, origin)), and there are exactly two origins:TYPE(a damage type claimed them) andROOTS(they fell back to the attacker's spirit roots).DamageEventBridgereduces with either, but only hands theTYPEreading toElementReactionService#applyFromStrike— the fire in a body decides how hard a hit lands and does not set anyone alight. That is what keeps "a root shapes the damage, a weapon leaves the mark" apart, and why a strike has to declare what it is made of before it can start a reaction. resolveTypeturns a declaration into a type.mxt:damage/mxt:damage_targetuse a declareddamage_typeverbatim; with onlyelementthey take the first type that element claims; with both they check that the element really claims that type — a mismatch logs one line per distinct complaint but never fails the load.- Why the check cannot happen at load time: datapack registries load in parallel, so a cross-registry value is not necessarily bound yet, and the same pack would pass or fail depending on which page finished first. So the check runs on first use instead, reports each distinct complaint once, and lets the run continue.
- With no server, the answer is an empty set rather than an exception: a client-side script asking what a hit is made of must not be able to bring the client down.
Sources and credit
DamageCalculationService#source is the only place a damage source is built; call sites do not each decide attribution:
- A declared
damage_type→new DamageSource(type, attacker), so the kill counts as the attacker's. - No declared type → vanilla's
playerAttack/mobAttack, orgenericwhen there is no attacker.
Keeping this here rather than at every call site makes "who this hit belongs to" and "what this hit is worth" come from one place. One consequence is easy to forget: vanilla only scales playerAttack / mobAttack damage by difficulty when the victim is a player, so a source that must not vary with difficulty has to declare its own damage_type.
The return value of deal is "the amount handed to the target", not "the health actually lost". When hurtServer returns false — invulnerability ticks, immunity, a cancelled event — deal still returns shaped. A caller that wants to know whether the hit landed should look at the hurtServer result or the events, not at this number.
The paths that were folded in
| Entry point | How it reaches layer one | Credit |
|---|---|---|
mxt:damage (entity action) | DamageAction#execute → deal | The caster, unless they are the target; self-damage has none |
mxt:damage_target (bi-entity action) | DamageTargetBiEntityAction#execute → deal(ctx.actor(), …) | The actor |
mxt:explode (entity action) | A ShapedCalculator wraps vanilla's explosion calculator; only getEntityDamageAmount goes through outgoing | The caster, as the explosion's cause |
A formation attack module | FormationActionRunner#attack → deal | Decided by attribute_to_owner (default true) |
| Actions inside a tribulation timeline | The timeline runs the same mxt:damage-style actions | Whatever the action itself says |
Deliberately outside layer one: mxt:spawn_lightning (vanilla computes the bolt's damage, and cause only decides attribution) and the block form of mxt:explode — neither goes through deal at all. A projectile spawned by mxt:spawn_projectile records the caster as its owner but still leaves its impact damage to vanilla (an arrow's power is not datapack-driven). All of them still pass through layer two, so as long as an element claims those damage types (lightning, explosion, lava, fall…), their element is still computed; the projectile case additionally still has the attacker, so its element is read either way.
Why the explosion wraps a calculator: vanilla computes a separate damage number per entity, and the only place to intervene is ExplosionDamageCalculator#getEntityDamageAmount. So mxt:explode wraps the vanilla calculator and overrides that one method — block destruction and "should this entity be damaged" are forwarded unchanged. Without it, a caster's explosion would be the one hit in the mod that ignores their progression level and their spirit roots.
Declaring a hit in the datapack
mxt:damage and mxt:damage_target each take two optional fields that declare what the hit is:
| Field | Type | Default | Description |
|---|---|---|---|
damage_type | Holder<damage_type> | none | The damage type of this hit. With it the source is built from it; without it an attributed hit uses the vanilla player/mob attack source (generic when mxt:damage has no attribution). |
element | HolderOrTag<element>[] | [] | The element declaration of this hit. Writing only element makes the damage type the first type that element claims; when it is written together with damage_type, the first strike that actually uses them checks that the element really claims that type and logs one line per distinct mismatch. |
The element is still only read from the damage type, so element is not a second source of truth: it writes the author's intent into the definition and lets the runtime check it, so a declared element whose type nobody claims is reported the first time it is used rather than quietly computing a wrong number in combat. That check deliberately cannot live at load time — the value behind a declared element is not necessarily bound yet while another datapack registry page is decoded, so the same pack would pass or fail depending on which page finished first. The reduction layer is the reason the element has to be readable from a damage type at all: it receives nothing but a DamageSource.
Without a declared
damage_type, attributed damage uses vanilla'splayerAttack/mobAttacksource: kill credit goes to the attacker, and vanilla scales that damage by difficulty only when the victim is a player. Write adamage_typewhen you want a source whose damage does not move with the difficulty.
Three things that are not part of this pipeline, worth saying out loud:
- The damage type → element mapping is declared entirely by data packs.
element.damage_typesis the only place that says a type means an element, and the mod hard-codes no mapping at all. A claim wins; the attacker's spirit roots are the fallback when nobody claims the type. - Artifacts (
artifact) contribute no attack or defence numbers of their own — they reach combat through the abilities they grant and the attributes they carry. - Damage the framework never shaped still passes layer two: adaptation is a property of the entity rather than of this mod's attacks.
The pass-through tag: mxt:no_bonus
Some damage should never be touched by any of this, so the mod keeps a tag on the vanilla damage_type registry:
data/mxt/tags/damage_type/no_bonus.json{
"replace": false,
"values": [
"minecraft:out_of_world",
"mxt:lifespan"
]
}A damage type listed there skips both layers: no damage_multiplier, no element_modifier, no overcomes, no adapted_to, no physique multiplier — and no element is left on the target either (ElementReactionService does not take part at all). The number is handed to vanilla exactly as it arrived, and vanilla's own mitigation still applies (armour, enchantments, resistance, absorption, invulnerability ticks). The tag says "this hit is not the mod's to scale", not "this hit is immune".
A few things to know:
- It is decided by damage type, so every source is treated alike: this mod's actions, another mod's hit and vanilla environmental damage all pass through if their type is in the tag. The three execution points are
deal(layer one),DamageEventBridge(layer two and element buildup) and the damage calculator ofmxt:explode, all sharing one predicate. - The default holds two: the void,
minecraft:out_of_world, andmxt:lifespan: the 4 damage per tick for falling out of the world is an execution by position, with no "who is stronger" to speak of, and any scaling or reduction would make being killed by the void depend on a technique or a physique;mxt:lifespanis the cause this mod uses for a non-player creature whose lifespan runs out (the death message key isdeath.attack.mxt.lifespan), and it is a one-hit kill that should not be swayed by a technique, a physique or an element relation either. A player whose lifespan runs out never takes this damage — a player is turned into a spectator. - A pack can add to it: the same tag path with
"replace": falseappends to the defaults, and"replace": truereplaces them outright (which also takes the void back out). - It changes neither credit nor kill statistics — it only keeps the hit out of the bonus arithmetic.
Trying it in game
The test module ships /mxt_test damage, which drives both layers against throwaway entities with known element edges and reports whether the amounts it measured are the ones it expected. It also runs two more legs: "damage with nobody credited reads no relation but keeps the casting's bonus", and "a type in mxt:no_bonus passes through" (the latter also asserts that the shipped default tag was actually loaded).
Element buildup and reactions
In the same event, the element set used for reduction is handed straight to ElementReactionService#applyFromStrike: it builds up each element on the target by that element's own damage_attachment, then tries to fire an element reaction (reactions have their own chain limit so they cannot cascade forever). The source is read once and used twice so that "the element used to reduce" and "the element used to accumulate" cannot be two different answers.
Three rules apply to that step:
- Only a declared element accumulates. The origin comes from the same reading (
DamageElements.Strike'sorigin), and onlyOrigin.TYPE(a claimed damage type) is handed to the buildup —Origin.ROOTS(the attacker's spirit roots) takes part in reduction alone. The fire in a body does not set anyone alight; weapons and techniques do. - The artifacts the victim carries decide how much gets through. The amount is first multiplied by
DamageCalculationService#attachmentMultiplier(target): theattachment_multiplierdeclared by the artifacts the target carries (both hands and the Curios slots), multiplied together (only artifacts carry this field —item_bindingandweapon_bindingno longer do), where0.5lets half through and0lets none. This is the item-side answer to resisting an elemental reaction — it slows the buildup, so a reaction answers later or never; what the reaction does (its damage, for instance) is not its business and stays with the reaction'sactionand layer two. - How much also depends on which damage type the strike was. The amount is not the element's alone: a claimed type (or tag) may carry its own
damage_attachment(see element, "A claimed type is a group"). The pipeline resolves it in the same reading that classifies the strike (Strikecarries the elements, the origin and the amounts), so "a lava bath builds up slower than a fireball" is one line in a pack.
This lives here for the same reason the reduction does: a lava bath and another mod's fire spell build up fire on a body exactly like our own fireball, as long as their damage types are claimed.
Costs and limits
- Every hit computes. The reverse index is cached (built once per datapack reload), but
Elements#ofreads the spirit-root registry and builds a set every call, and both multipliers are O(n×m) double loops with no memoisation. Hits are far rarer than ticks, so this is an accepted trade-off; a caller asking the same question repeatedly inside one settlement has to hold on to the answer itself. - The first build is synchronous: the first strike of a session walks the whole element registry once inside a lock and freezes the result; everything after that is a lookup.
- Pack mistakes only log. A declaration that disagrees with its type, several elements claiming one type, a tag-only element declaration that cannot name a type — each reports once and the run continues. Check the log after editing elements; do not expect a failed start.
- No floor on reduction and no ceiling on multiplication: several elements being overcome multiply repeatedly. Capping or flooring is the pack's job.
- Ordering on
LivingIncomingDamageEventis undefined: this mod declares no priority, so another mod that editsamounton the same event has no guaranteed order relative to this layer.
See also
- element — every field of
overcomes/adapted_to/damage_types/attachment_*, and how a damage type gets claimed. - progression — how
damage_multiplieris chosen. - Formula Variables — reading
damage_multiplierinside a definition's own formulas. - Entity Actions and Bi-entity Actions — the full field tables of
mxt:damageandmxt:damage_target. - Java API —
DamageCalculationServiceand its method signatures.