Advancements
Verified against Minecraft 26.3 · Part XIII · “Stone Age”: a cobblestone lands in your inventory and the toast is on its way before the next tick ends — delivered by a subscription table that shrinks as you play, over a packet that never says what a criterion tests.
Mine a stone block. Nothing about advancements happens when the item enters
the inventory. What happens is that the pickup’s ServerPlayer.take runs
AbstractContainerMenu.broadcastChanges — the same diff that keeps
your client’s inventory in sync (containers and
menus)
— which notices that a slot’s contents differ from its remembered copy, and reports
the difference. Detection is a diff, not
an event, and the advancement system is a subscriber to it.
That is the first of two things this system does backwards from expectation. The second is bigger: there is no global list of who is listening for what. Each player carries their own subscription table, it contains only the criteria that player has not yet satisfied, and it shrinks as they play. A veteran player is cheaper to run than a new one, and a save’s cost falls as its players play.
Which is also why advancements are the game’s general-purpose “tell me when
the player does X” facility rather than only a goal list. The recipe book
is unlocked by advancements. PlayerPredicate reads advancement progress
back out as a loot condition. Of the fifty-eight registered triggers, fifty-seven are hooks something in the game fires, and the remaining one, minecraft:impossible, is never fired at all.
The cast
| class | what it decides | side |
|---|---|---|
Advancement | the immutable definition — parent, DisplayInfo, rewards, criteria, requirements, and a pre-rendered display name built in the six-argument constructor, which is the [Title] every announcement quotes | both |
AdvancementHolder | the id plus the advancement, with id-only equality — so a map keyed by holder survives a pack changing an advancement’s contents | both |
AdvancementTree | the parent/child graph, built by a fixed-point loop that refuses any advancement whose parent is not yet a node. An orphan is discarded, not re-rooted | both |
Criterion / CriterionTrigger | a trigger plus a decoded CriterionTriggerInstance, and nothing else — a criterion has no name of its own, the name is the map key, and the trigger object is stateless | server |
SimpleCriterionTrigger | the base class for all but one trigger, and the owner of the per-fire sweep | server |
AdvancementRequirements | a list of lists of criterion names: an AND of ORs. AdvancementRequirements.size counts clauses, not criteria | both |
PlayerAdvancements | the per-player subscription table and the dirty sets. The only class here with interesting state | server |
TreeNodePosition | a full tidy-tree layout — three walks, threads, ancestors, shifts — run on the server, mutating AdvancementNode’s coordinates in place | server |
The shared model is net/minecraft/advancements, the triggers are in
advancements/triggers and the predicates in advancements/predicates
(with the entity half a level down). All 111 classes ship in both jars — the
side column above says where a class does its work, not which jar carries
it — and PlayerAdvancements sits in net/minecraft/server beside them.
CriteriaTriggers registers fifty-eight triggers into
BuiltInRegistries.TRIGGER_TYPES over forty-four classes; the gap is
re-use, with PlayerTrigger alone accounting for six registrations.
From a slot that changed to a toast
minecraft:story/mine_stone has one criterion, get_stone, whose trigger
is minecraft:inventory_changed and whose condition is a single
ItemPredicate over the #minecraft:stone_tool_materials tag. It has no
rewards at all.
sequenceDiagram
box Server
participant SP as ServerPlayer
participant ACM as Abstract<br/>ContainerMenu
participant ICT as Inventory<br/>ChangeTrigger
participant PA as PlayerAdvancements
end
box Client
participant CPL as ClientPacketListener
participant CAdv as ClientAdvancements
end
rect rgba(0, 0, 0, 0.04)
Note over SP,PA: the pickup, inside ServerPlayer.doTick, after the levels
SP->>ACM: broadcastChanges, from ServerPlayer.take
ACM->>SP: ContainerListener.slotChanged, on the player's own listener
SP->>ICT: trigger — count all 43 slots first
ICT->>ICT: SimpleCriterion<br/>Trigger.trigger
ICT->>ICT: test the changed stack
ICT->>PA: award, after the sweep
PA->>PA: unregisterListeners, then the rewards
PA->>PA: markForVisibilityUpdate — the root
end
rect rgba(0, 0, 0, 0.04)
Note over SP,PA: the next tick's ServerPlayer.tick
SP->>PA: flushDirty, second to last in the tick
PA->>PA: updateTreeVisibility — the whole tree
end
PA->>CPL: ClientboundUpdate<br/>AdvancementsPacket
CPL->>CAdv: update — rebuild, reconcile progress, add a toast
One stone pickup through the advancement system — the award happens inside the pickup, after the levels have ticked, and the one packet that tells the client waits for the next tick’s flush.
The paragraphs below take its arrows in order.
Counting comes before knowing whether anyone cares. The slot change
reaches the trigger through the ContainerListener every ServerPlayer
installs on its own menus, whose ContainerListener.slotChanged fires
CriteriaTriggers.INVENTORY_CHANGED.
InventoryChangeTrigger.trigger walks all forty-three slots — thirty-six
inventory plus seven equipment — to compute the occupied, full and empty
counts before it asks whether any criterion is listening. That is the floor cost of every change to a player’s inventory, forever. (The one fired unconditionally is CriteriaTriggers.TICK, once a tick for every player.)
The sweep itself is the cheap part. SimpleCriterionTrigger.trigger
fetches this trigger’s map from PlayerAdvancements.getTriggerMapForType and
returns immediately if it is null — and it is null once every criterion
for that trigger is satisfied, because PlayerAdvancements.removeListener
deletes the per-trigger map when it empties. When there is work, it builds
one LootContext and reuses it for the whole sweep, evaluates the
caller’s cheap matcher first and the player predicate only for matches,
and does not allocate the results list until the first hit. Note the scope:
one player. Nothing here is a broadcast.
Matches are collected, then awarded, because awarding calls
PlayerAdvancements.unregisterListeners, which mutates the very map being
iterated.
Completion is checked against the requirements, not the criteria.
AdvancementRequirements.test is the AND of ORs. Here it is one clause of
one name, so granting the criterion completes the advancement — which fires
the rewards, the chat announcement (built by
AdvancementType.createAnnouncement, broadcast to every player, gated on the display’s own flag and on GameRules.SHOW_ADVANCEMENT_MESSAGES) and the visibility dirty flag.
The rewards are AdvancementRewards.EMPTY here, as for most advancements outside the recipe book.
The record holds experience, loot tables, recipes and a function, and the
recipe field is why the recipe book works at all: every recipe advancement
is generated with a RecipeUnlockedTrigger criterion and rewards naming the
recipe, so earning it calls ServerPlayer.awardRecipes
(recipes).
RecipeUnlockedTrigger then closes the loop by letting other advancements
observe an unlock — comparing the recipe key by reference identity,
which is safe only because ResourceKeys are interned. The function reward
is a CacheableFunction, which holds the id and resolves it once
(functions and macros).
The table shrinks, and the two things that refill it
PlayerAdvancements.registerListeners subscribes a player only to criteria
that are not yet done in advancements that are not yet done, and every award
unsubscribes. The longer you play, the smaller your table, and the cost of the system to a save falls as its players play.
Two things run it backwards. /advancement revoke re-subscribes what it
revoked, and a reload — /reload, or /datapack enabling or disabling a pack — re-subscribes everything unfinished in the new pack.
Neither is something the game does on its own, which is why the table shrinks as you play rather than only ever shrinking.
Two oddities sit on the same machinery. ImpossibleTrigger has no trigger
method at all — it is the one trigger implementing CriterionTrigger
directly, and it exists so a node can anchor a tree while being ungrantable
except by command, which vanilla uses for exactly one file: the invisible
root of every recipe advancement. (It has nothing to do with /trigger,
which is a scoreboard feature.) And
PlayerAdvancements.checkForAutomaticTriggers is dead code in the strictest
sense: it walks every advancement on every player load, but its whole body
sits behind this advancement has no criteria at all, and Advancement’s
criteria codec rejects an empty map outright. No loaded advancement can
satisfy the guard, so the loop never does anything.
Visibility is per root, and it gates the wire
PlayerAdvancements.markForVisibilityUpdate dirties the root, and the
flush — PlayerAdvancements.updateTreeVisibility, once per dirty root — re-runs AdvancementVisibilityEvaluator (the “how far past your frontier can you see” rule, with a depth of two) over that root’s entire subtree. Finishing one advancement in a large tree re-evaluates the whole
tree, and only the nodes whose visibility flipped go on the wire.
PlayerAdvancements.flushDirty then sends one
ClientboundUpdateAdvancementsPacket, with progress only for advancements
in PlayerAdvancements.visible. Progress on something hidden, or beyond
your frontier, accumulates server-side and reaches the client the moment it
becomes visible.
Where the flush sits in the tick is worth pinning down, because it produces
a real one-tick delay that nobody expects. Inside ServerPlayer.tick,
AbstractContainerMenu.broadcastChanges is the fifth statement,
CriteriaTriggers.TICK fires mid-tick, and
PlayerAdvancements.flushDirty is second to last, with only a
post-effects check after it. So everything awarded between those points — a minecraft:tick criterion, or an item that another advancement’s reward grants, whose diff runs inside that award — coalesces into one packet, and so does everything awarded earlier in the tick: a melee kill arrives in the packet drain of MinecraftServer.processPacketsAndTick, and a typed /advancement grant as a task the server runs before it, both before the levels tick at all.
But ServerPlayer.tick is not the last thing that happens to a player: it is
only the first of the two brackets a player is ticked in (the two-phase
tick).
ServerGamePacketListenerImpl.tick calls ServerPlayer.doTick during the
connection phase, which runs after the levels
(the server tick) — i.e. after
PlayerAdvancements.flushDirty has already run. CriteriaTriggers.LOCATION, which fires there
every twenty ticks and is what most vanilla biome and structure
advancements hang on, therefore always lands in the next tick’s packet —
and so does any award a pickup earns, the stone above included, because
ServerPlayer.take runs the diff there.
A criterion’s conditions are loot conditions
Each entity condition a trigger carries — the player test every simple
trigger has, a kill’s victim — is one LootItemCondition, held in a
registry holder and evaluated against a LootContext reached through
EntityPredicate.createContext. So
a trigger’s conditions are exactly the machinery of
contexts and predicates, and that is
where most descriptions of the system stop.
It is worth going one step further, because the predicate package is where four shapes were invented that the whole data-driven half of the game now reuses.
| shape | what it generalises | where else it turns up |
|---|---|---|
MinMaxBounds | the numeric range, with both a codec and a StringReader grammar | 3..7 means the same in a predicate, an entity selector and /random |
CollectionPredicate | one generic “N of these match”, composing CollectionContentsPredicate and CollectionCountsPredicate | its only users are the seven component predicates in core/component/predicates |
EntitySubPredicate | every part of an entity predicate as a registry element instead of a code branch | its twenty-five kinds are each a record and a codec, a few with a builder beside them |
DataComponentMatchers | testing a stack’s components without knowing what any of them are | data components |
Two details change behaviour rather than shape.
LootItemCondition.CODEC accepts either the id of a data-pack predicate
or one inline condition — though vanilla’s own advancements take the inline
form every time, so in a criterion the reference exists for pack authors
rather than for the game.
And EntityPredicate declares an explicit type-check-first, NBT-last
ordering for its own sub-tests — a performance invariant hiding inside a
predicate class.
The screen at the other end
The client’s half is five classes in net/minecraft/client/gui/screens/advancements, ClientAdvancements over in client/multiplayer, and the toast, and it is the payoff for
everything the server did. It does no tree layout: it is drawing
positions a data-pack reload decided, which is why the tree looks the same
on every client connected to a server.
The layout itself is TreeNodePosition, run once per root by AdvancementTree.repositionNodes
from ServerAdvancementManager’s constructor — which a reload runs on a background worker,
after RegistryDataLoader has read the JSON (the resource
system).
It writes each AdvancementNode’s coordinates in place, and they ride the packet in
ClientboundUpdateAdvancementsPacket.PositionedAdvancement. A
root with no DisplayInfo is never laid out and never becomes a tab, and a
display-less node in the middle of a tree is transparent — the layout skips
it and adopts its children. One wrinkle in an otherwise deterministic
algorithm: AdvancementNode.children is an unordered hash set, so sibling
order inside a tidy-tree layout is hash-dependent.
AdvancementsScreen is a ClientAdvancements.Listener, and its AdvancementsScreen.onAdvancementsUpdated
rebuilds every tab from the tree. ClientAdvancements.setListener makes that call on a new listener
at once, which is how the screen catches up on open. Each packet is applied by
ClientAdvancements.update: it removes and adds tree nodes, places each added one at the coordinates
the packet carries, reconciles every progress it was sent against that advancement’s requirements, and — for each
now done whose display asks for it — adds an AdvancementToast, which plays
a sound only for a challenge. Then it makes the same call, so an open screen rebuilds after every packet.
AdvancementsScreen owns the tab strip; AdvancementTab owns one root’s
pan-and-scroll bounds, auto-centres on first render and clamps the drag;
AdvancementWidget scales the server-decided coordinates by fixed factors,
draws the connector lines to its parent, and wraps its own tooltip text.
AdvancementTabType is the one with a hard limit in it: four header
geometries with room for eight tabs above, eight below and five on each
side. A data pack’s twenty-seventh root is silently unreachable.
Two more things ride this boundary for almost nothing.
ServerboundSeenAdvancementsPacket has a “closed screen” action that is
serialised, deserialised and dropped. And Advancement.sendsTelemetryEvent
is consumed only on the client, by WorldSessionTelemetryManager, and
only for advancements in the minecraft namespace — which is the whole
reason a flag rides a wire form that drops the criteria and the rewards
(what this book skips).
What the package holds that this page does not name
Two families make up most of net/minecraft/advancements, and the answer to
both is the same: the page explains the shape and the members are instances of
it. The forty triggers it does not explain — KilledTrigger,
FishingRodHookedTrigger, RecipeCraftedTrigger and the rest — are each a
record, a codec and a CriterionTriggerInstance, registered by CriteriaTriggers and fired
from wherever in the game the thing happens; knowing SimpleCriterionTrigger
is knowing all of them. The concrete predicates under
advancements/predicates are the same story one level down:
StatePropertiesPredicate, EntityFlagsPredicate, EntityEquipmentPredicate,
MobEffectsPredicate, DamageSourcePredicate and their neighbours are each a small record over one kind of thing, the entity ones instances of EntitySubPredicate, and the one that matters most to another part, LocationPredicate, is named where it does work, in contexts and predicates.
Questions players ask
Why does the client show “3/7” if it does not know what the criteria
are? Because AdvancementRequirements is on the wire and
AdvancementProgress.update reconciles against it. Advancement.STREAM_CODEC
reconstructs the record with an empty criteria map and
AdvancementRewards.EMPTY, so a client cannot know what any criterion
tests, or that an advancement grants anything at all. And
AdvancementProgress.getProgressText returns nothing at all when there is
one clause, which is why a single-criterion advancement never shows “1/1”.
A criterion’s whole per-player state is one class,
CriterionProgress, and one field in it: a nullable timestamp. That is why
the wire form can carry progress without carrying a single condition.
(An AdvancementProgress with no requirement clauses cannot be complete — AdvancementRequirements.test returns false for an empty list rather than vacuously true — and every progress starts that way, decoded from the wire or the disk or built empty, until AdvancementProgress.update gives it its requirements, which both sides do at once.)
Does /reload roll back my progress? No, and the order is the point: a
reload’s completion list saves every player before it reloads them (the
resource
system),
so PlayerAdvancements.reload re-reads a file written moments earlier. What is genuinely lost is progress for any
advancement the new pack has removed or renamed — logged once each, and
invisible to the player except as a full reset packet — plus the selected
tab, which is silently forgotten with no packet, so the client keeps a stale
one.
When is progress written to disk? Only when the player is saved. There is
no write on award: PlayerAdvancements.save runs from PlayerList.save, on disconnect, on every save of the world — the autosave, /save-all, shutdown — or from the reload above. The definitions come
from data/<ns>/advancement/<id>.json through Advancement.CODEC — a
file that fails to decode aborts the reload outright — and per-player state is one JSON
file at players/advancements/<uuid>.json
(LevelResource.PLAYER_ADVANCEMENTS_DIR), data-fixed on load through
DataFixTypes.ADVANCEMENTS.
Why is /advancement grant usable as a conditional? Because a no-op is
a hard failure: granting an advancement the player already has throws rather
than reporting zero. AdvancementCommands.Mode — only, through, from, until — picks one advancement or collects its parents or children or both (its everything constant goes unused), and /advancement grant … everything, which takes every advancement directly, calls
PlayerAdvancements.flushDirty before the loop with the packet’s “show
advancements” flag true and after it with the flag false, which is
the only purpose that flag has: suppressing a toast storm from the batch it
just granted.
Why does the toast stay silent? Because AdvancementToast asks for a
sound only when the advancement is an AdvancementType.CHALLENGE — one of the three
AdvancementTypes, and the one that also gets its own title colour. For a
task and a goal the method returns nothing at all, so the toast slides in
in silence. The client can tell which it is because DisplayInfo carries the
type; what it cannot tell is whether the advancement was meant to announce
itself in chat, because that flag never goes on the wire. The
serialiser packs three flags into an int and omits it, and the reader
hard-codes it false while the codec defaults it true, so the client’s copy is
wrong for the common case rather than merely unused.
Where to look
Advancement and AdvancementRequirements for the model;
PlayerAdvancements for everything that happens;
SimpleCriterionTrigger and InventoryChangeTrigger for the hot path;
MinMaxBounds and CollectionPredicate for the predicate shapes that recur
everywhere else; and AdvancementVisibilityEvaluator for the one rule
nobody guesses right.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.