Attributes
Verified against Minecraft 26.3 · Part VI · Strength II is applied to a player: one modifier lands on one attribute, no attribute packet goes on the wire, and the swing three seconds later reads the new number.
Strength II lands on you and thirty seconds later it wears off. In between,
one AttributeModifier — an amount of +6, an operation of
AttributeModifier.Operation.ADD_VALUE, an id of effect.strength — sits on
one attribute of one entity, and every swing you make asks for that attribute
and gets a bigger number back. The same mechanism is behind armour points, a
horse’s jump strength, the extra reach of a creative-mode player and the
distance at which a mob notices you: ask a question, get a number, cheaply,
with a defined order of operations. What is surprising is what does not
happen. Strength II sends no attribute packet at all. The effect itself
goes out, but eight of the forty registered attributes are not client-syncable
and Attributes.ATTACK_DAMAGE is one of them, so for the whole thirty seconds
your own client’s copy of your attack
damage sits at the base value it was born with — 1.0 — and nothing ever tells
it otherwise.
A different system with the same words.
world/attributeis environment attributes — per-position world properties like the sky’s colour (environment attributes and timelines) — with its own registries and its own class also namedAttributeModifier. Nothing on this page refers to it.
The cast
| class | what it decides | thread |
|---|---|---|
Attribute | one named number’s default, its description id, its Attribute.Sentiment (tooltip colour only) and the one boolean that decides whether the client is ever told | built in the Attributes class initialiser, read from every thread after |
RangedAttribute | the minimum and the maximum, and RangedAttribute.sanitizeValue — the clamp. The only subclass, and every registered attribute is one | as above |
AttributeSupplier | what attributes an EntityType has at all, and their base values. Frozen | built at class-init, inside DefaultAttributes |
AttributeMap | which of two dirty sets a change lands in, and therefore whether a packet is sent | the Server thread for mutations, the Render thread for the mirror |
AttributeInstance | the number: a base value, three modifier indices, a dirty flag and a cache | as above |
AttributeModifier | an Identifier, an amount and an operation. A record, and inside an instance the identifier alone is its identity | immutable, shared |
LivingEntity | when the update set drains, and what reacts to a change | both sides, in LivingEntity.tick |
ServerEntity | when the sync set drains and what goes on the wire | the Server thread, in the level tick’s chunkSource phase |
Four objects, two dirty sets, and one list that is neither
flowchart TD
ATTR["Attribute, always a RangedAttribute: a range, and a syncable flag"]
SUP["AttributeSupplier: one frozen prototype map per EntityType"]
MAP["AttributeMap: one per LivingEntity, only what was asked for"]
INST["AttributeInstance: a base value, a cached value, a dirty flag"]
UPD["AttributeMap.attributesToUpdate: every dirtied attribute"]
SYNC["AttributeMap.attributesToSync: only the syncable ones"]
MOD["AttributeMap.onAttributeModified"]
PAIR["AttributeMap.getSyncableAttributes: not a set, a filter"]
REACT["LivingEntity.refreshDirtyAttributes, in the entity's own tick"]
SEND["ServerEntity.sendDirtyEntityData, the ServerLevel.chunkSource phase"]
WIRE["ClientboundUpdateAttributesPacket"]
ATTR -- "registered once" --> SUP
SUP -- "one per EntityType" --> MAP
MAP -- "AttributeMap.getInstance" --> INST
INST -- "AttributeInstance.setDirty" --> MOD
MOD -- "always" --> UPD
MOD -- "and if syncable" --> SYNC
MAP -- "when tracking starts" --> PAIR
UPD --> REACT
SYNC --> SEND
SEND --> WIRE
PAIR --> WIRE
The four objects and the three collections — and every collection is the
AttributeMap’s, which is the thing to look at: the instance only calls
AttributeInstance.setDirty, and the map decides which sets that lands in.
Four objects carry the whole system — the Attribute, the frozen
AttributeSupplier per type, the AttributeMap per entity, and the
AttributeInstance per number asked for. The instance keeps three
lists of its own, and they belong to a later section;
the three in the figure all belong to the map.
Two of them are the dirty sets, and they are not a partition:
AttributeMap.onAttributeModified always adds to the update set and
additionally to the sync set when the attribute is syncable, so a syncable
attribute is in both and a non-syncable one is in the update set alone. The
third is not a dirty set at all:
AttributeMap.getSyncableAttributes filters the whole live map every time it is
asked, and it exists for one caller — ServerEntity.sendPairingData, which has
to describe an entity to a player who has never seen it and so cannot work from
what has changed since last time.
Forty numbers, every one of them clamped
The forty constants of Attributes register themselves in their own static
field initialisers, into BuiltInRegistries.ATTRIBUTE under
Registries.ATTRIBUTE. Attributes.bootstrap does nothing but return
Attributes.MAX_HEALTH, and exists only as the class-loading trigger
BuiltInRegistries needs — which is also why a data pack can reference an
attribute but never add one.
Every one of the forty is a RangedAttribute, and RangedAttribute is the
only subclass of Attribute, so every attribute in the game has a minimum
and a maximum and every computed value passes through one clamp. Several of
those bounds are the reason a mechanic behaves the way it does:
Attributes.MAX_HEALTH has a minimum of 1, so no entity’s maximum health can
reach zero, and Attributes.KNOCKBACK_RESISTANCE has a minimum of −2, so
amplified knockback is a legal value rather than a bug. The full list — id,
constant, default, minimum, maximum, syncable, sentiment — is
the attribute table.
The syncable flag, and the eight that never travel
What has to be said here is the syncable flag, because it explains most of
what a client and a server disagree about. Eight of the forty never reach
the client: Attributes.ATTACK_DAMAGE, Attributes.ATTACK_KNOCKBACK,
Attributes.KNOCKBACK_RESISTANCE, Attributes.FOLLOW_RANGE,
Attributes.TEMPT_RANGE, Attributes.SPAWN_REINFORCEMENTS_CHANCE — whose
registry id is spawn_reinforcements, disagreeing with its own constant name
— and the pair Attributes.WAYPOINT_TRANSMIT_RANGE and
Attributes.WAYPOINT_RECEIVE_RANGE. An attribute is syncable only because
its registration line called Attribute.setSyncable, and that setter is
public and has no freeze behind it, on an object that lives in a registry.
Nothing calls it after bootstrap. Nothing stops it either.
The client is not left with a blank where an unsyncable attribute should be,
which is the part that makes the silence hard to notice: both sides run the
same Attributes class initialiser and build the same DefaultAttributes
prototypes, so your client’s copy of your attack damage holds the prototype’s
1.0 from the moment the entity exists. It is not missing. It is a number that
was right once and is never corrected — and the client reads it: your own
client runs Player.attack for your swing, which asks for your attack damage
(authority).
The prototype, frozen at class-init
DefaultAttributes holds one AttributeSupplier per EntityType, each
built by chaining builders: LivingEntity.createLivingAttributes is the
twenty-six-entry base, Mob.createMobAttributes adds
Attributes.FOLLOW_RANGE at 16, Monster.createMonsterAttributes adds
Attributes.ATTACK_DAMAGE and nothing else, and each species’ own builder
finishes the job. AttributeSupplier.Builder.build keeps the last entry
for a repeated attribute, so a species builder does not have to know what its
ancestors declared: of the five attributes Zombie.createAttributes names,
four are already in the chain and are simply overwritten — attack damage from
the monster builder, follow range from the mob builder, movement speed and
armour from the twenty-six-entry living base — and only
Attributes.SPAWN_REINFORCEMENTS_CHANCE is new. Anything
outside MobCategory.MISC with no supplier at all is logged by
DefaultAttributes.validate.
A prototype is frozen in the sense that AttributeSupplier.Builder.build
arms a callback that throws on any later write: reading one is fine, dirtying
one throws. Reading an attribute the type does not have is also fatal — the
fallback in AttributeSupplier raises rather than returning a default, which
is why LivingEntity.getAttribute is nullable and
LivingEntity.getAttributeValue is not.
Player.createAttributes is on Player, not on Avatar. That intermediate
class (entity anatomy)
owns the player-shaped hitbox but not the attribute set, so Mannequin — the other Avatar —
is registered with LivingEntity.createLivingAttributes and gets the plain
living set, including the registry’s default movement speed of 0.7 rather
than a player’s 0.1. The default is not a dead value: the wandering trader
is registered with the bare Mob builder, which sets no speed either, and
walks on it.
The map, and which set a change lands in
AttributeMap starts empty. AttributeMap.getValue and
AttributeMap.getBaseValue answer from the prototype when the entity has no
instance of its own, and change nothing. AttributeMap.getInstance is
different: it creates the instance on demand through
AttributeSupplier.createInstance, which copies the frozen template with
AttributeInstance.replaceFrom — which ends in AttributeInstance.setDirty.
So asking for an instance is a mutation: the first call to
LivingEntity.getAttribute for a syncable attribute enqueues it for
broadcast before any modifier exists, and a share of the attribute packets a
busy server sends are caused by something merely asking.
The two sets drain in two different phases
The two sets drain in different phases of the same tick, and that is where
the visible lag comes from. ServerEntity.sendDirtyEntityData is reached
from ChunkMap.tick, which runs inside ServerLevel.tick’s chunkSource
phase — before the entities phase (the level
tick). An attribute dirtied during an
entity’s own tick (equipment, an effect, powder snow, anything in
ServerPlayer.updatePlayerAttributes) has therefore already missed this
tick’s send, and a dirty attribute set is not one of the three things that
open ServerEntity.sendChanges’s gate (synched entity
data owns that
gate), so it waits for the next tick whose
count is a multiple of the entity’s EntityType.updateInterval (up to two
ticks for a player, whose interval is 2, and up to three for the default 3),
unless something else opens the gate first. Only a mutation made before the
chunk-source phase — a player’s command, an interaction handled out of the
packet queue at the top of the server tick — can reach the wire in the tick that produced
it, and then only if the gate opens. It is
the same phase ordering that puts a block entity’s writes a tick late
(block entities).
The update set drains in the entity’s own tick — the entities phase for a mob,
the connection phase for a player — in
LivingEntity.refreshDirtyAttributes, which calls
LivingEntity.onAttributeUpdated once per dirtied attribute and then clears
the set. That hook has exactly four branches: clamp health down to a reduced
maximum health, clamp absorption, call Entity.refreshDimensions on a scale
change, and register or unregister the transmitted waypoint with the
ServerWaypointManager. Subclasses add two more —
ServerPlayer.onAttributeUpdated takes the receive half of the waypoint
pair, and Mob.onAttributeUpdated recomputes the pathfinder’s node budget
through PathNavigation.updatePathfinderMaxVisitedNodes on a change to
Attributes.FOLLOW_RANGE or Attributes.TEMPT_RANGE
(pathfinding owns that budget).
LivingEntity.refreshDirtyAttributes is called from LivingEntity.tick with
no side check, so the client runs LivingEntity.onAttributeUpdated too,
clamping health and resizing an entity whose scale changed — which is why the
waypoint branch inside it is the one that has to test for a ServerLevel
explicitly.
A frozen mob is the worked example
Everything above has a cost attached, and the cheapest way to see it is a mob
standing in powder snow. LivingEntity.aiStep calls
LivingEntity.removeFrost and LivingEntity.tryAddFrost back to back,
server-side, with no test for whether anything changed. Each has a gate — the
remove only dirties when the modifier is there, the add needs a
non-air block underfoot and a non-zero frozen counter — but when both hold,
the pair destroys and re-creates a modifier on Attributes.MOVEMENT_SPEED,
dirtying a syncable attribute twenty times a second and re-sending that
entity’s whole movement-speed modifier list for as long as it stays frozen.
Compare ServerPlayer.updatePlayerAttributes, which runs just as often but
uses AttributeInstance.addOrUpdateTransientModifier with a constant modifier
object, and so dirties nothing after the first tick. The whole difference is
whether each write dirties anything at all.
What the map writes to disk
Two more things the map decides, and between them they answer why did my
/attribute change survive death? AttributeMap.pack writes every
instantiated instance, base value included, so a base value set by command
persists with no modifier attached to carry it — AttributeInstance.pack
writes only the permanent modifiers of each. And on respawn,
ServerPlayer.restoreFrom always calls AttributeMap.assignBaseValues but
calls AttributeMap.assignPermanentModifiers only on a full restore:
returning from the End, not an ordinary death. Base values always come
across, a command-added modifier only sometimes.
The instance: three indices and one cached number
An AttributeInstance keeps its modifiers three times over: bucketed by
AttributeModifier.Operation for the arithmetic, indexed by Identifier for
lookup and duplicate detection, and a second id-index of the permanent ones
for saving. A modifier’s identity is its Identifier alone — there is no
UUID and no name — so two systems that pick the same identifier for the same
attribute collide, and AttributeInstance.addTransientModifier and
AttributeInstance.addPermanentModifier throw rather than silently
overwrite. AttributeInstance.addOrUpdateTransientModifier and
AttributeInstance.addOrReplacePermanentModifier are the safe forms. Vanilla
mostly removes by id before it adds, or uses the safe forms; three mob classes
and AttributeCommand guard with AttributeInstance.hasModifier instead.
Transient versus permanent is about keeping: both kinds sit in the
same indices, both affect the value identically, both go on the wire, and
only the permanent ones are packed, or carried across a return from the End. Mob-effect modifiers are added
permanently, and that is the only reason an effect’s modifier survives a
reload (status
effects). On
the client, meanwhile, every modifier is transient, because
ClientPacketListener.handleUpdateAttributes sets the base value, wipes the
whole modifier list and re-adds the incoming ones with
AttributeInstance.addTransientModifier. A client attribute map is never
persisted.
AttributeInstance.getValue recomputes through
AttributeInstance.calculateValue only when the dirty flag is set, and the
flag starts true, so the first read always computes — three passes and one
clamp:
flowchart TD
B["the base value: the prototype's, or whatever has set it since"]
P1["pass 1: every ADD_VALUE modifier, added"]
P2["pass 2: every ADD_MULTIPLIED_BASE modifier, off the same base"]
P3["pass 3: every ADD_MULTIPLIED_TOTAL modifier, off the running total"]
C["RangedAttribute.sanitizeValue, once"]
O["AttributeInstance.cachedValue, to the next AttributeInstance.setDirty"]
B --> P1 --> P2 --> P3 --> C --> O
The three passes, in the order they always run. What separates pass 2 from
pass 3 is the two words on their boxes: the same base against the running total.
That difference is the whole of the arithmetic. Every
AttributeModifier.Operation.ADD_MULTIPLIED_BASE modifier reads the base as pass 1 left it, so two of
them do not compound — +50% and +50% is +100%. Every
AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL modifier reads the previous one’s output, so two of
those do — +50% and +50% is +125%. The clamp runs once at the end:
RangedAttribute.sanitizeValue collapses a not-a-number result to the
minimum and otherwise holds the value between the minimum and the maximum.
Operation order is therefore global, not insertion order, and intermediate
values are never clamped. Within a bucket, iteration order is a hash map’s —
safe only because each bucket’s arithmetic is commutative.
…except in the other implementation, which is insertion-ordered
ItemAttributeModifiers.compute is a second, disagreeing implementation of
the same idea. It walks an item’s ItemAttributeModifiers.Entry list in
declaration order, applying each entry’s operation to the running total as it
goes, with no three-pass grouping at all. It is not a duplicate of
AttributeInstance.calculateValue and it does not agree with it. Its one
caller in the whole game is Mob.getApproximateAttributeWith — the “would
this weapon or armour be better than what I have?” estimate a mob makes when
deciding whether to pick an item up.
Where the modifiers come from
Equipment is the busiest source. LivingEntity.detectEquipmentUpdates, in
the server-only half of LivingEntity.tick, only dispatches;
LivingEntity.collectEquipmentChanges does the work, adding each incoming
stack’s modifiers as transient (removing by id first) through
ItemStack.forEachModifier, which merges
DataComponents.ATTRIBUTE_MODIFIERS with the enchantment modifiers from
EnchantmentHelper.forEachModifier. Exactly eight vanilla enchantments carry
EnchantmentEffectComponents.ATTRIBUTES — fire and blast protection,
respiration, aqua affinity, depth strider, swift sneak, sweeping edge,
efficiency — and EnchantmentAttributeEffect has a second, location-based
path through EnchantmentAttributeEffect.onChangedBlock that exactly one
uses: soul speed, registered under
EnchantmentEffectComponents.LOCATION_CHANGED instead.
The rest, in one breath: MobEffect declarations, permanently and scaled by
amplifier; item components, whose ItemAttributeModifiers.Display decides
whether and how a tooltip line appears — its default form adds the reader’s
own base value back in for Item.BASE_ATTACK_DAMAGE_ID and
Item.BASE_ATTACK_SPEED_ID, so a weapon shows a total rather than a bonus;
SetAttributesFunction in loot tables; Zombie.handleAttributes at spawn;
LivingEntity.setSprinting; ServerPlayer.updatePlayerAttributes; and
AttributeCommand, whose modifier add is permanent, and so saved.
One packet, one direction: ClientboundUpdateAttributesPacket, server to
every tracking player and to the entity itself when it is a player — which is why your own
client has a live attribute map at all. At most 128 attributes fit in one
(checked on encode as well as decode), each snapshot carrying the attribute
holder, the base value and the complete, uncapped modifier list. There is no
serverbound attribute packet.
Strength II lands, and no attribute leaves the server
Put the four objects, the two sets and the two phases together and the whole system runs once, end to end, for a number the wire never hears about:
sequenceDiagram
participant EffC as EffectCommands
participant LE as LivingEntity
participant ME as MobEffect
participant AttrM as AttributeMap
participant AttrI as AttributeInstance
participant SE as ServerEntity
EffC->>LE: addEffect, Strength at amplifier 1
LE->>LE: onEffectAdded, guarded server-side
LE->>ME: addAttributeModifiers, the map and the amplifier
ME->>AttrM: getInstance, Attributes.ATTACK_DAMAGE
AttrM->>AttrI: replaceFrom, inside AttributeSupplier.createInstance, if new
AttrI->>AttrM: onAttributeModified, into the update set, if new
ME->>AttrI: removeModifier, then addPermanentModifier at plus 6
AttrI->>AttrM: onAttributeModified, into the update set
Note over LE,SE: not syncable, so the sync set stays empty
rect rgba(0, 0, 0, 0.04)
Note over LE,SE: the next server tick: the ServerLevel.chunkSource phase, then the player's own tick
SE->>SE: sendDirtyEntityData finds an empty attribute set
LE->>LE: refreshDirtyAttributes drains the update set
end
Note over LE,AttrI: three seconds later, inside Player.attack
LE->>AttrM: getValue, Attributes.ATTACK_DAMAGE
AttrM->>AttrI: getValue, dirty, so calculateValue
AttrI-->>AttrM: 1.0 base, plus the sword's, plus 6.0
AttrM-->>LE: the same number
The lane to watch is ServerEntity: it is reached, finds the attribute sync
set empty, and sends no attribute packet. The effect itself does go out, as a
mob-effect packet when it is added and as the swirl in the entity data, but
the attack damage never does.
MobEffects.STRENGTH is declared with one attribute modifier: +3 on
Attributes.ATTACK_DAMAGE, AttributeModifier.Operation.ADD_VALUE, under
the id effect.strength, and the amount is multiplied by amplifier + 1 when
the modifier is built — so Strength II is +6. (MobEffects.WEAKNESS is
the same construction at −4.) LivingEntity.addEffect puts the instance into
the active list and calls LivingEntity.onEffectAdded, which is guarded
server-side and hands the entity’s AttributeMap to
MobEffect.addAttributeModifiers. If the effect was already running, the
path is LivingEntity.onEffectUpdated instead, which removes and re-adds and,
unlike the add path, refreshes the dirty attributes on the spot.
MobEffect.addAttributeModifiers calls AttributeMap.getInstance, and that
is where the instance for Attributes.ATTACK_DAMAGE is born if nothing has
asked for it yet (a player holding a sword already has one, made when the
sword was equipped), copying the frozen prototype’s base value: 1.0 for a
player, 3.0 for a zombie. Creating it dirties it, before any modifier exists.
Then the effect removes its own id, which dirties nothing when the id is not
there, and adds the +6 with AttributeInstance.addPermanentModifier — the
remove is not optional, since the plain add throws on a duplicate id — which
dirties it. Each time the
callback adds to the update set and consults the syncable flag before touching
the sync set, so the sync set stays empty and no attribute packet is sent at
all. Next tick, ServerEntity.sendDirtyEntityData finds no attribute to send
(it does send the swirl the effect put in the entity data), and
LivingEntity.onAttributeUpdated matches none of its four branches.
Three seconds later Player.attack asks LivingEntity.getAttributeValue for
the attack damage. The instance is dirty, so AttributeInstance.calculateValue
runs: the addition bucket holds the sword’s base_attack_damage modifier
from DataComponents.ATTRIBUTE_MODIFIERS and the effect’s +6, the two
multiplication buckets are empty, the clamp passes, the result is cached. The
rest of the swing — cooldown scale, enchantment bonuses, the crit — belongs
to the sword swing. Expiry runs it backwards:
LivingEntity.onEffectsRemoved calls MobEffect.removeAttributeModifiers,
which removes by id from all three indices and dirties one last time.
Swap Strength for Speed and the missing limb appears
MobEffects.SPEED targets Attributes.MOVEMENT_SPEED with
AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL, and movement speed is
syncable. Everything above is identical until the callback, which now fills
the sync set too. ServerEntity.sendDirtyEntityData drains it in the next
chunkSource phase and emits a ClientboundUpdateAttributesPacket carrying
the base value and every modifier on that attribute — sprinting, powder
snow, the effect, all of them — and ClientPacketListener.handleUpdateAttributes
rebuilds the client’s instance from scratch. That difference, one boolean on
the Attribute, is the whole design.
Where to look
Attributes is forty field initialisers and the syncable flag is visible in
each of them, so start there; DefaultAttributes and
AttributeSupplier.Builder.build are the prototypes and the freeze. Then read
the three methods this page turns on, in this order:
AttributeMap.getInstance, because asking is a mutation;
AttributeMap.onAttributeModified, the four lines that decide which set a
change lands in; and AttributeInstance.calculateValue, the three passes and the clamp. LivingEntity.refreshDirtyAttributes and
ServerEntity.sendDirtyEntityData are where the two sets drain, in different
phases. Two doors: ItemAttributeModifiers.compute, the second and disagreeing
implementation of the arithmetic, and AttributeCommand, whose modifier add
is the plainest way a player can make a permanent modifier by hand.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.