Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Advancements

Verified against Minecraft 26.2 · Part XIII · “Stone Age”: a cobblestone lands in your inventory and one tick later the toast appears — delivered by a subscription table that only ever shrinks, over a packet that never says what the criterion was.

Mine a stone block. Nothing about advancements happens when the item is picked up; nothing happens when it enters the inventory either. What happens is that AbstractContainerMenu.broadcastChanges — the same diff that keeps your client’s inventory in sync — 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 the cost of the whole system to a save file falls monotonically over its life.

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. All fifty-eight registered triggers exist because something in the game wanted a hook and this was the hook that already existed.

The cast

classwhat it decidesside
Advancementthe immutable definition — parent, DisplayInfo, rewards, criteria, requirements, and a pre-rendered display name built in the compact constructor, which is the [Title] every announcement quotesboth
AdvancementHolderthe id plus the advancement, with id-only equality — so a map keyed by holder survives a pack changing an advancement’s contentsboth
AdvancementTreethe 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-rootedboth
Criterion / CriterionTriggera 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 statelessboth
SimpleCriterionTriggerthe base class for all but one trigger, and the owner of the per-fire sweepboth
AdvancementRequirementsa list of lists of criterion names: an AND of ORs. AdvancementRequirements.size counts clauses, not criteriaboth
PlayerAdvancementsthe per-player subscription table and the dirty sets. The only class here with interesting stateserver
TreeNodePositiona full tidy-tree layout — three walks, threads, ancestors, shifts — run on the server, mutating DisplayInfo’s coordinates in placeserver

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 112 classes ship in both jars. 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.

The trace: “Stone Age”

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
    participant ACM as AbstractContainerMenu
    participant SP as ServerPlayer
    participant ICT as InventoryChangeTrigger
    participant PA as PlayerAdvancements
    participant AR as AdvancementRewards
    participant CPL as ClientPacketListener
    participant CAdv as ClientAdvancements

    ACM->>ACM: broadcastChanges — this slot differs from lastSlots
    ACM->>SP: ContainerListener.slotChanged — which slot, which stack
    SP->>ICT: trigger(player, inventory, stack) — count the 43 slots FIRST
    ICT->>ICT: SimpleCriterionTrigger.trigger — is this player listening for this trigger at all?
    ICT->>ICT: TriggerInstance.matches — one predicate, so test only the changed stack
    ICT->>PA: award(mine_stone, get_stone) — after the sweep, never during
    PA->>PA: unregisterListeners — the criterion is done, stop watching
    PA->>AR: grant(player) — EMPTY here. XP, loot, recipes and a function otherwise
    PA->>PA: markForVisibilityUpdate — the ROOT, not the advancement
    Note over SP,PA: still the same tick — flushDirty is the last statement of ServerPlayer.tick
    SP->>PA: flushDirty
    PA->>PA: updateTreeVisibility — re-walk the whole story tree
    PA->>CPL: ClientboundUpdateAdvancementsPacket — added, removed, visible progress
    CPL->>CAdv: update — rebuild the tree, reconcile the progress
    CAdv->>CAdv: AdvancementToast — and silent unless it is a CHALLENGE

Each arrow is a decision.

Counting comes before knowing whether anyone cares. 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 slot change of every player, forever, and it is the most expensive trigger per fire. (The most frequent is CriteriaTriggers.TICK, which fires unconditionally twenty times a second per 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 GameRules.SHOW_ADVANCEMENT_MESSAGES) and the visibility dirty flag.

Visibility is per root, and it gates the wire

PlayerAdvancements.markForVisibilityUpdate dirties the root, and the flush 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 actually flipped go on the wire.

PlayerAdvancements.flushDirty then sends 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 the last. So everything awarded between those points — a pickup, a kill, an /advancement grant, an item granted by another advancement’s reward — coalesces into one packet, and so does everything that arrived in a packet, because MinecraftServer.processPacketsAndTick drains the inbound queue before the levels tick at all.

But ServerPlayer.tick is not the last thing that happens to a player. ServerGamePacketListenerImpl.tick calls ServerPlayer.doTick during the connection phase, which in 26.2 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.

A criterion’s conditions are loot conditions

ContextAwarePredicate wraps a list of LootItemCondition and evaluates it 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.

shapewhat it generaliseswhere else it turns up
MinMaxBoundsthe numeric range, with both a codec and a StringReader grammar3..7 means the same in a predicate, an entity selector and /random
CollectionPredicateone generic “N of these match”, composing CollectionContentsPredicate and CollectionCountsPredicateits only users are the six component predicates in core/component/predicates
EntitySubPredicatea per-mob test as a registry element instead of a code branchthe twenty-odd small entity predicates are each a record, a codec and nothing else
DataComponentMatcherstesting a stack’s components without knowing what any of them aredata components

Two details change behaviour rather than shape. EntityPredicate.ADVANCEMENT_CODEC accepts either a condition list or a bare entity predicate — though vanilla’s own JSON takes the long form every time, so the short one 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.

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.read 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”. (An AdvancementProgress with no requirement clauses is permanently incompletable — AdvancementRequirements.test returns false for an empty list rather than vacuously true — and the only way to hold one is to decode it off the wire, which is exactly what the client does before its first update.)

Why does the tree look the same on every client? Because it was laid out on the server. TreeNodePosition runs inside ServerAdvancementManager and mutates DisplayInfo in place; the coordinates ride the packet. 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.

Does /reload roll back my progress? No, and the order is the point. MinecraftServer.reloadResources calls PlayerList.saveAll and then PlayerList.reloadResources, 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 a save-all, or from the reload above. The definitions come from data/<ns>/advancement/<id>.json through Advancement.CODEC — a duplicate id 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.

How does the recipe book fit in? Every recipe advancement is generated with a RecipeUnlockedTrigger criterion and an AdvancementRewards 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.

Does the listener set ever grow? Twice. PlayerAdvancements.registerListeners subscribes only to criteria that are not yet done in advancements that are not yet done, and every award unsubscribes — unless somebody runs /advancement revoke, which re-subscribes, or /reload, which re-subscribes everything unfinished in the new pack.

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.Modeonly, through, from, until, everything — is a graph traversal collecting parents or children or both, and /advancement grant … everything 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.

Three smaller surprises, for completeness. 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.) DisplayInfo’s announce-to-chat flag is write-only 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. 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.

The screen at the other end

The client’s half is five classes in net/minecraft/client/gui/screens/advancements plus ClientAdvancements over in client/multiplayer — about 1,240 lines — and it is the payoff for everything the server did. It does no tree layout: it is drawing positions a data-pack reload decided.

ClientAdvancements consumes AdvancementTree.Listener, and AdvancementTree.setListener replays every existing root and task at a new listener immediately, which is how the screen catches up on open. 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 a fixed factor, 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 and go nowhere. 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).

Where to look

Advancement and AdvancementRequirements for the model; PlayerAdvancements for everything that actually 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.