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.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

classwhat it decidesside
Advancementthe 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 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 statelessserver
SimpleCriterionTriggerthe base class for all but one trigger, and the owner of the per-fire sweepserver
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 AdvancementNode’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 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.

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 seven component predicates in core/component/predicates
EntitySubPredicateevery part of an entity predicate as a registry element instead of a code branchits twenty-five kinds are each a record and a codec, a few with a builder beside them
DataComponentMatcherstesting a stack’s components without knowing what any of them aredata 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.