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

Containers and menus

Verified against Minecraft 26.2 · Part VII · A player shift-clicks a stack out of a chest: one packet goes up, the server re-runs the same code against the real container, and nothing comes back down.

You are standing at a chest with a stack of cobblestone in the top-left slot, and you shift-click it. Your machine moves the stack into your hotbar immediately, and one ServerboundContainerClickPacket leaves for the server. The server runs the same method against the real ChestBlockEntity, gets its own answer, compares the two — and if they agree, sends nothing at all. That is the steady state: one packet up, zero packets down. There is no transaction acknowledgement in this protocol; agreement is silence. What makes silence safe is the part nobody expects. The click packet does not carry the stacks the client thinks it produced, it carries a CRC32C hash per changed slot, and before comparing anything the server installs that claim as its own belief about the client. It adopts the client’s belief object, never the client’s data.

The cast

classwhat it decidesthread
Containerstorage, and nothing about who is looking at it — Container.getItem, Container.setItem, Container.stillValidwherever its owner runs
AbstractContainerMenuthe slot list, the cursor, the state id, the click state machine, and the two baselines (one for listeners, one for the wire)both main threads
SlotGUI policy — Slot.mayPlace, Slot.mayPickup, Slot.getMaxStackSize — and the guarded mutations a click goes throughboth main threads
Inventorythe player’s own storage, present as slots in nearly every menu that opens — the lectern’s one book slot is the exceptionboth main threads
MenuType / MenuProviderthe registry entry with the screen-side constructor, and the server-side factory a block hands to ServerPlayer.openMenuclient / server main
ContainerSynchronizerthe diffing channel to the connection, and every menu’s writer but one — one per ServerPlayer, shared by every menu that player opensserver main
RemoteSlotwhat the server believes the client is holding in one slot, as either a stack or a hashserver main (RemoteSlot.PLACEHOLDER on the client)
HashedStackthe client’s claim about one slot after its own clickcreated on the client, matched on the server

The chest you see is not the chest

The server’s ChestMenu is built from a MenuProvider that ChestBlock.getMenuProvider produces and ChestBlock.useWithoutItem hands to ServerPlayer.openMenu (block interaction), and its Container is the real ChestBlockEntity — or a CompoundContainer over both halves, for a double chest, from a separate anonymous provider. The client’s ChestMenu is built from MenuType’s screen-side constructor by MenuScreens, and that constructor makes a fresh SimpleContainer. The client’s chest contents have no connection to the block entity beyond the packet stream. Its AbstractContainerMenu.stillValid would be a lie for the same reason — SimpleContainer.stillValid returns true unconditionally — but the question is never put to it: every call site of AbstractContainerMenu.stillValid in the game is on the server. A client never closes its own menu because it walked away.

The other half of that asymmetry is the synchronizer. Only ServerPlayer.initMenu ever calls AbstractContainerMenu.setSynchronizer, so a client menu has none, and every one of its RemoteSlots stays RemoteSlot.PLACEHOLDER, whose RemoteSlot.matches always answers true, so the wire half of AbstractContainerMenu.broadcastChanges is inert on the client by construction. The listener half is not: BeaconScreen, ItemCombinerScreen, LecternScreen and the creative inventory all register a real ContainerListener, which is how the anvil’s rename field repopulates itself after a slot changes.

Three smaller facts about the model that a reader will otherwise trip over. A menu with a null MenuType cannot be opened over the network at all — AbstractContainerMenu.getType throws — which is why the player’s own InventoryMenu is pinned to InventoryMenu.CONTAINER_ID, zero, and built independently on both sides, and why the mount menus need a ClientboundMountScreenOpenPacket of their own instead of ClientboundOpenScreenPacket. Every other menu takes an id from ServerPlayer.nextContainerCounter, which cycles 1 to 100 and never reaches zero. And Slot.mayPlace and Container.canPlaceItem are unrelated questions: the first is GUI policy and defaults to true without consulting the container at all, the second is what hopper automation reads. A player and a hopper can have different rights over the same slot. So can a player and an item: Container.getMaxStackSize defaults to 99, and the familiar 64 comes only from the per-stack overload, which takes the minimum with the item’s own maximum.

One shift-click, end to end

A three-row ChestMenu numbers slots 0–26 for the chest, 27–53 for the player’s three main inventory rows and 54–62 for the hotbar, because AbstractContainerMenu.addStandardInventorySlots adds the main rows before the hotbar. Watch what the wire carries, and what it does not.

sequenceDiagram
    participant MPGM as MultiPlayerGameMode
    participant ChestM as ChestMenu
    participant Wire as the network
    participant SGPL as ServerGamePacketListenerImpl
    participant ACM as AbstractContainerMenu
    participant RemS as RemoteSlot
    participant CSync as ContainerSynchronizer

    MPGM->>MPGM: copy every slot's stack, before touching anything
    MPGM->>ChestM: clicked with QUICK_MOVE, predicted on the twin
    ChestM->>ChestM: quickMoveStack, then moveItemStackTo backwards
    MPGM->>Wire: ServerboundContainerClickPacket, state id plus changed slots as hashes
    Wire->>SGPL: handleContainerClick, at the top of the server tick
    SGPL->>ACM: suppressRemoteUpdates, then the same clicked on the real chest
    SGPL->>ACM: setRemoteSlotUnsafe per claimed hash, then setRemoteCarried
    ACM->>RemS: receive, which throws away any concrete stack it held
    SGPL->>ACM: resumeRemoteUpdates, then broadcastChanges
    ACM->>RemS: matches?
    RemS->>RemS: the hash agrees, so adopt the server's stack as the copy
    RemS-->>CSync: nothing, sendSlotChange is never reached
    CSync-->>Wire: nothing goes down

The press. AbstractContainerScreen.mouseClicked resolves the hovered slot, sees an empty AbstractContainerMenu.getCarried and a held shift, and chooses ContainerInput.QUICK_MOVE on the press. Two near neighbours are release-path instead, from AbstractContainerScreen.mouseReleased: a shift-click with a non-empty cursor, and the shift-double-click sweep, which issues a separate ContainerInput.QUICK_MOVE — and a separate packet — for every matching slot in the same container.

The snapshot, then the prediction. MultiPlayerGameMode.handleContainerInput copies every slot’s stack before touching anything, because that copy is what the packet’s diff is built against. Then it runs AbstractContainerMenu.clicked on the client’s own menu. The ContainerInput.QUICK_MOVE branch loops AbstractContainerMenu.quickMoveStack while the clicked slot keeps yielding the same item, and it runs only for button 0 or 1, a non-negative slot index and a slot that Slot.mayPickup allows.

The move, and its two passes. ChestMenu.quickMoveStack sees the index is inside the chest and calls AbstractContainerMenu.moveItemStackTo over the player’s range with the backwards flag, which scans from the last slot down — the hotbar first, from the right. That method makes two passes. The first runs only if the stack is stackable at all, and merges into every existing compatible stack across the whole range (ItemStack.isSameItemSameComponents, topped up to Slot.getMaxStackSize). The second runs only if something is left, finds the first empty slot that Slot.mayPlace accepts, places what that slot’s cap allows and breaks. One empty slot per call — which is why the caller loops. Note which pass consults policy: the merge pass never asks Slot.mayPlace, so a slot that would refuse the item on placement can still be topped up by a shift-click when it already holds a matching stack. Note also what ChestMenu.quickMoveStack never calls: Slot.onTake. InventoryMenu.quickMoveStack does. Shift-clicking out of a chest and out of the crafting result are structurally different operations.

The packet. The client diffs the post-click slots against its snapshot, hashes each changed stack with HashedStack.create, and sends ServerboundContainerClickPacket with the container id, the client’s last-known state id, the slot, the button, the ContainerInput, up to 128 changed slots as hashes, and the cursor as one more.

The real move. The server takes the ladder below, then — with AbstractContainerMenu.suppressRemoteUpdates held — runs the identical AbstractContainerMenu.clicked path against the real ChestBlockEntity and the real Inventory. Slot.setChanged reaches BlockEntity.setChanged, which calls Level.blockEntityChanged to mark the chunk and Level.updateNeighbourForOutputSignal to re-derive the comparator output (block entities). The whole click is wrapped in a try/catch that builds a crash report category naming the menu class, the slot and the button.

Installing the claim, then comparing. AbstractContainerMenu.setRemoteSlotUnsafe writes each hash the client sent into the matching RemoteSlot; RemoteSlot.Synchronized discards any concrete stack it was holding and keeps the hash alone. An out-of-range index is logged at debug and ignored rather than rejected. Then AbstractContainerMenu.setRemoteCarried, then AbstractContainerMenu.resumeRemoteUpdates, then AbstractContainerMenu.broadcastChanges, which makes one pass over the slots offering each to AbstractContainerMenu.triggerSlotListeners for advancements and to AbstractContainerMenu.synchronizeSlotToRemote for the wire, then the cursor, then the data slots. Where the hash agrees, RemoteSlot.Synchronized promotes it to a concrete copy of the server’s own stack and nothing is sent.

The advancement channel sees one state per click, not one per slot touched: AbstractContainerMenu.triggerSlotListeners runs only from AbstractContainerMenu.broadcastChanges and AbstractContainerMenu.broadcastFullState — and for a chest nothing calls back into the menu mid-click to reach either. That is a fact about the chest, not about menus: CrafterSlot, the anvil’s and the smithing table’s ItemCombinerMenu slots and the crafting grid all call AbstractContainerMenu.slotsChanged from Container.setChanged, and the base AbstractContainerMenu.slotsChanged is a bare AbstractContainerMenu.broadcastChanges. The click’s own broadcast runs after AbstractContainerMenu.resumeRemoteUpdates, so suppression is not in force by then, and ServerPlayer’s ContainerListener filters to slots that are not a ResultSlot and whose container is the player’s own Inventory before firing CriteriaTriggers.INVENTORY_CHANGED anyway.

The ladder the server climbs before it believes you

ServerGamePacketListenerImpl.handleContainerClick is four tests and a fork, and the interesting thing about it is how much of it ends in nothing sent rather than a correction.

flowchart TD
    P["ServerboundContainerClickPacket, on the server main thread via PacketUtils.ensureRunningOnSameThread"] --> ID{"does containerId match the open menu?"}
    ID -->|"no"| D1["dropped in total silence, nothing logged, nothing sent"]
    ID -->|"yes"| SPEC{"spectator, or dead or dying?"}
    SPEC -->|"yes"| D2["sendAllDataToRemote, a full resync, and the click never runs"]
    SPEC -->|"no"| SV{"AbstractContainerMenu.stillValid"}
    SV -->|"fails"| D3["logged at debug, nothing sent, the menu is not closed here"]
    SV -->|"passes"| IX{"AbstractContainerMenu.isValidSlotIndex"}
    IX -->|"fails"| D4["logged at debug, nothing sent, nothing corrected"]
    IX -->|"passes"| ST["compare the packet's state id with the menu's, BEFORE anything is applied"]
    ST --> AP["suppressRemoteUpdates, run clicked, install the claimed hashes, resumeRemoteUpdates"]
    AP --> Q{"was that state id stale?"}
    Q -->|"stale"| FULL["broadcastFullState, ending in sendAllDataToRemote, one ClientboundContainerSetContentPacket with a fresh state id"]
    Q -->|"current"| BC["broadcastChanges, every slot against its RemoteSlot"]
    BC --> AG{"RemoteSlot.matches"}
    AG -->|"agrees"| SIL["the hash becomes a concrete copy of the server's stack, and nothing is sent"]
    AG -->|"disagrees"| ONE["one ClientboundContainerSetSlotPacket for that slot, with a fresh state id"]

The order of the last two boxes is the load-bearing part: the state id is compared before the click is applied and acted on after, so a click that quotes a stale id still runs, and still runs first. Its result is simply published wholesale instead of diffed.

AbstractContainerMenu.isValidSlotIndex deserves suspicion. It accepts −1, accepts AbstractContainerMenu.SLOT_CLICKED_OUTSIDE, and otherwise only asks whether the index is below the slot count — so every negative index passes it. The branches that need a floor test for it themselves; the two that do not, ContainerInput.SWAP and the painting phase of ContainerInput.QUICK_CRAFT, index the list directly, and the click’s own try/catch turns the failure into a ReportedException rather than swallowing it: what swallows it is PacketProcessor, which logs a game-listener error and carries on. An out-of-range click is therefore neither corrected nor fatal, but it is loudly logged, and it closes nothing: ServerGamePacketListenerImpl.handleContainerClose for its part validates nothing at all, not even the container id, and goes straight to ServerPlayer.doCloseContainer.

Why hashes, and why only in one direction

HashedStack.create produces either HashedStack.EMPTY or a HashedStack.ActualItem of item holder, count and a HashedPatchMap — one CRC32C integer per added component plus the plain set of removed ones. Each integer comes from running the component’s own codec into HashOps.CRC32C_INSTANCE, a DynamicOps whose output is the hash (codecs, NBT and JSON owns that mechanism). Nothing is serialised on the way and there is no intermediate byte form. HashedPatchMap.matches then checks the removed set, the added count, and each component’s hash in turn.

Two things follow. The client is asserting a belief, not authoring state — a hash cannot be turned back into an item, so a client that lies here can only fail to match — and the traffic is 128 integers rather than 128 full DataComponentPatches. The asymmetry is exact: only the client ever calls HashedStack.create, and only the server ever calls HashedStack.matches. Hashing is not free-standing on the server either: ServerPlayer’s ContainerSynchronizer carries a 256-entry component-hash cache shared by every menu that player opens, and ContainerSynchronizer.createSlot hands each new RemoteSlot.Synchronized a HashedPatchMap.HashGenerator backed by it.

One channel is deliberately outside all of this. DataSlot and ContainerData — furnace progress, enchanting cost, lectern page — travel as ClientboundContainerSetDataPacket, whose id and value are written as shorts, and they carry two independent baselines: DataSlot.checkAndClearUpdateFlag for the listeners and AbstractContainerMenu.remoteDataSlots for the network, compared separately. The network comparison is a plain integer test, so a furnace’s progress bar is not covered by the hash-agreement silence and is re-sent every time it changes.

The state id, and the three places it moves

AbstractContainerMenu.incrementStateId is a wrapping 15-bit counter — it masks to 32767 — and it answers exactly one question: has the client applied every slot correction I have sent? It has three call sites in the whole game. Two are inside ServerPlayer’s synchronizer, behind ContainerSynchronizer.sendInitialData and ContainerSynchronizer.sendSlotChange; ContainerSynchronizer.sendCarriedChange and ContainerSynchronizer.sendDataChange do not bump it — and ClientboundSetCursorItemPacket carries no id whatever, while ClientboundContainerSetDataPacket carries the container’s but never a state id. The third is CraftingMenu.slotChangedCraftingGrid, below. The client never generates one: AbstractContainerMenu.setItem and AbstractContainerMenu.initializeContents simply store whatever arrived and quote it back on the next click. A click quoting a stale id means corrections are still in flight, and the server stops diffing and resends everything.

Where in the tick a broadcast happens

Not one place, and the difference is observable. Packets are drained before any level ticks (the server tick), so a click and any correction it produces both happen at the top of the tick and reach the client the same tick. ServerPlayer.tick then calls AbstractContainerMenu.broadcastChanges again from the level’s entity phase, which runs before the block-entity phase (the level tick). And ServerPlayer.doTick — driven by the connection after every level has finished — repeats only the AbstractContainerMenu.stillValid distance test, without a broadcast.

So the distance test happens twice a tick and only the first is accompanied by a broadcast. A hopper that pushes an item into a chest whose menu is open therefore runs in the block-entity phase, after that tick’s only broadcast, and nothing calls back into the menu to say soSimpleContainer.setChanged is empty, BlockEntity.setChanged marks the chunk and re-derives the comparator output and stops there, Inventory.setChanged only bumps a counter. You see the hopper’s item one tick late.

Closing has its own surprise. The cursor belongs to the menu, not the player, so closing one would destroy it: AbstractContainerMenu.removed rescues it explicitly, dropping it in the world if the player has been removed or has disconnected and calling Inventory.placeItemBackInInventory otherwise, the whole method gated on being a ServerPlayer, which is what makes that safe. The rescue sends one ClientboundSetPlayerInventoryPacket per slot it fills, and runs before AbstractContainerMenu.transferState, which copies both the listener baseline and the remote beliefs across every container-and-slot pair the closing menu and InventoryMenu share. For a chest that is the 36 main and hotbar slots — not armour, not the offhand, not the 2×2 grid, not the crafting result — so changes to those four are re-sent and nothing else is.

The seven click kinds

ContainerInput has seven values, and the button number means something different in every one. ClickActionClickAction.PRIMARY and ClickAction.SECONDARY — is not on the wire; it is derived inside AbstractContainerMenu.doClick on both sides and handed to the item override hooks ItemStack.overrideStackedOnOther and ItemStack.overrideOtherStackedOnMe, which is how a BundleItem intercepts a click before ordinary slot logic runs.

valuethe gesturewhat the button number meanspackets per gesture
ContainerInput.PICKUPthe ordinary click0 left, 1 right — and on AbstractContainerMenu.SLOT_CLICKED_OUTSIDE, drop all or drop one1
ContainerInput.QUICK_MOVEshift-click0 or 1, the same in a real slot — and outside the window, the drop-all and drop-one of ContainerInput.PICKUP1, or one per matching slot for the shift-double-click sweep
ContainerInput.SWAPa hotbar key, or Inventory.SLOT_OFFHAND for the offhand keythe destination index: 0–8, or 401
ContainerInput.CLONEcreative middle-clickignored, but the player must pass Player.hasInfiniteMaterials1
ContainerInput.THROWQ over a slot, cursor empty — and also a click outside the window with an empty cursor, which the server’s branch then ignores because it demands a non-negative index0 drops one, 1 (control-Q) drops the stack and keeps going while the slot yields the same item1
ContainerInput.QUICK_CRAFTdrag-paintinga packed mask that AbstractContainerMenu.getQuickcraftHeader and AbstractContainerMenu.getQuickcraftType split into a phase (start, continue, end) and a mode (even split, one each, clone)painted slots plus twoAbstractContainerScreen.quickCraftToSlots sends a start, one continue per slot, and an end
ContainerInput.PICKUP_ALLdouble-click to collect0 scans forwards, 1 backwards, over two passes that skip already-full stacks first1

An unknown id on the wire is not rejected. ContainerInput’s id mapper is built with a zero-out-of-bounds strategy, so a malformed click decodes as ContainerInput.PICKUP.

Two paths that are not this protocol at all

Creative mode is a parallel protocol, not a variation. CreativeModeInventoryScreen overrides AbstractContainerScreen.slotClicked and drives a menu of its own, and its writes go up as ServerboundSetCreativeModeSlotPacket. That is the one packet in the game whose item data the server adopts — a rename, a sign and a jigsaw block are adopted as text: ServerGamePacketListenerImpl.handleSetCreativeModeSlot takes the client’s ItemStack verbatim into the slot through Slot.setByPlayer, behind only a Player.hasInfiniteMaterials check, a feature-flag check, a slot range of 1 to 45 and a count cap — then writes the same stack into the remote belief with AbstractContainerMenu.setRemoteSlot to suppress the echo. A negative slot number means drop it in the world instead, and that branch is the one place here with a rate limiter on it.

The crafting result is a second, unsuppressed channel. CraftingMenu.slotChangedCraftingGrid recomputes the result slot and then sends a ClientboundContainerSetSlotPacket straight down the connection, bumping the state id itself and bypassing ContainerSynchronizer entirely. It is reached from CraftingMenu.slotsChanged, CraftingMenu.finishPlacingRecipe and InventoryMenu.slotsChanged, and the first of those fires mid-click, because TransientCraftingContainer calls AbstractContainerMenu.slotsChanged from its own Container.setItem and from any Container.removeItem that took something. So the one path that transmits during a click is also the one path AbstractContainerMenu.suppressRemoteUpdates does not cover — that flag guards only AbstractContainerMenu.synchronizeSlotToRemote and its two siblings. Recipes is where the recomputation itself lives.

Everything else on the wire is bookkeeping around those two: ClientboundOpenScreenPacket and ClientboundContainerClosePacket for the lifetime, ClientboundContainerSetContentPacket and ClientboundSetCursorItemPacket for a resync, ServerboundContainerButtonClickPacket for the lectern, enchanting, loom and stonecutter buttons, ServerboundContainerSlotStateChangedPacket for crafter toggles, ServerboundSelectBundleItemPacket for a bundle, and ServerboundSetCarriedItemPacket with ClientboundSetHeldSlotPacket for the hotbar selection — which, despite the name, has nothing to do with the cursor. A structure chest fills itself on first open through RandomizableContainer.unpackLootTable (loot tables), and ContainerLevelAccessContainerLevelAccess.NULL on the client — is the position capability a block-anchored menu tests distance against. None of the click protocol is data-driven: BuiltInRegistries.MENU supplies only the type.

Where to look

ServerGamePacketListenerImpl.handleContainerClick · MultiPlayerGameMode.handleContainerInput · AbstractContainerMenu.doClick · AbstractContainerMenu.moveItemStackTo · AbstractContainerMenu.broadcastChanges · AbstractContainerMenu.setRemoteSlotUnsafe · RemoteSlot.Synchronized · HashedStack · HashedPatchMap · HashOps · ContainerSynchronizer · ContainerListener · Container · Slot · ChestMenu · InventoryMenu · CraftingMenu.slotChangedCraftingGrid · ContainerInput · ClickAction · DataSlot · MenuType · MenuProvider · MenuScreens · ServerPlayer.openMenu · ServerGamePacketListenerImpl.handleSetCreativeModeSlot · CreativeModeInventoryScreen


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.