Block interaction
Verified against Minecraft 26.3 · Part V · A player right-clicks the bottom half of an oak door, and the top half opens without a single neighbour update.
You are standing in front of a closed oak door, crosshair on its bottom half,
and you press the use key. Before the tick is over the door is open in your
client’s world, both halves of it, on your screen a frame or so later, and a packet is on its way to a server that has
not yet been asked. The obvious guess about how the top half found out is
wrong. Opening a door fires no neighbour updates at all — DoorBlock
writes with flags 10, and the neighbour bit is not among them — and the top
half follows anyway, down the shape channel, which is the half of the
update machinery the client also runs. That is why a door feels instant on a
laggy server and a redstone lamp does not.
The contract both halves run under. The client acts at once and remembers the state it overwrote, under a sequence number it sends with the action. The server’s
ClientboundBlockChangedAckPacketis a receipt for that number and not a verdict — once the server considers the client loaded, it is sent for actions the server refused exactly as for actions it allowed — and correctness comes from ordering instead: any correction the server means to send travels in the same tick and earlier in the stream than the receipt. A correction replaces what the client remembered rather than being weighed against it, so when the receipt arrives the client writes back whatever the entry now holds — and only where that differs from what is on screen. Prediction and acknowledgement owns that machinery; this page and block breaking are its two applications.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | that a use-key press becomes one Minecraft.startUseItem, and that the main hand is tried before the off hand | Render |
MultiPlayerGameMode | the client’s copy of the whole decision, wrapped in one prediction | Render |
InteractionResult | whether the caller stops here, who swings, and what the hand ends up holding | a sealed interface of records, no thread |
BlockBehaviour.BlockStateBase | which of the two block hooks a state answers with, and when its six neighbours are asked to re-fit | either side |
DoorBlock | whether this door opens by hand, what it writes, and what the other half becomes | either side |
CollectingNeighborUpdater | the order queued updates run in, and where a runaway cascade is cut | whichever thread wrote the block |
ServerGamePacketListenerImpl | the gate list the packet must pass, and what each refusal answers with | Server |
ServerPlayerGameMode | the same inner order as the client, plus the advancement triggers | Server |
The whole click, both sides
sequenceDiagram
box Client
participant MC as Minecraft
participant MPGM as MultiPlayer<br/>GameMode
participant CL as ClientLevel
participant DB as DoorBlock
participant CNU as Collecting<br/>NeighborUpdater
end
Note over MC,CNU: one client tick, all of it before the packet leaves
MC->>MPGM: useItemOn, main hand first, with the BlockHitResult
MPGM->>MPGM: startPrediction opens sequence n
MPGM->>DB: useWithoutItem, after useItemOn returned TRY_WITH_EMPTY_HAND
DB->>CL: setBlock, the lower half with OPEN cycled, flags 10
CL->>CNU: shapeUpdate, six directions, limit 511
CNU->>DB: updateShape on the upper half, direction DOWN
CNU->>CL: setBlock, the upper half through Block.updateOrDestroy
DB->>CL: playSound, the clicker as except
The client’s share of one click up to the moment the use packet is sent. The two writes are the page’s subject: the lower half is written by the door, the upper half by the shape pass that the first write set off, and no neighbour update happens at all.
Only now does the use packet leave the machine. It carries the hand, the hit and the sequence number, and the server runs the identical inner order against its own world.
sequenceDiagram
box Client
participant MPGM as MultiPlayer<br/>GameMode
participant CL as ClientLevel
end
box Server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SPGM as ServerPlayer<br/>GameMode
participant DB as DoorBlock
participant SL as ServerLevel
end
MPGM->>SGPL: ServerboundUseItemOnPacket, hand, hit and n
Note over MPGM,SGPL: no swing packet goes up: SUCCESS is predicted, so the client has already swung, and the server swings for the trackers itself
Note over MPGM,SL: server tick, packets drained before the levels tick
SGPL->>SGPL: ackBlockChangesUpTo n, then reach, hit box, height, spawn protection
SGPL->>SPGM: useItemOn, the same three-step order
SPGM->>DB: useWithoutItem
DB->>SL: setBlock lower half, then the shape pass writes the upper half
DB->>SL: playSound, everyone but the clicker, and gameEvent BLOCK OPEN
SGPL-->>CL: two ClientboundBlockUpdatePackets, the clicked position and the face neighbour
Note over MPGM,SL: still this tick, levels tick, then connections tick
SL-->>CL: ClientboundSectionBlocks<br/>UpdatePacket, both halves in one section
SGPL-->>CL: ClientboundBlockChangedAck<br/>Packet for n
CL->>CL: handleBlock<br/>ChangedAck<br/>ends prediction n
The server’s copy of the same click, and the three things that come back. The
receipt arrives last, and by the time it does the client has already been
corrected twice — which is why closing the prediction,
ClientLevel.handleBlockChangedAck, writes nothing.
One press, one hand at a time
Minecraft.handleKeybinds runs from the client tick and only when no screen
and no overlay is open. Every queued press of the use key becomes its own
Minecraft.startUseItem, unthrottled; Minecraft.rightClickDelay gates the
held-down auto-repeat alone, and Minecraft.startUseItem is what sets it, to
four ticks. The whole of that method sits behind one test —
MultiPlayerGameMode.isDestroying — so a use press arriving mid-dig does not
merely skip the delay, it is discarded entirely. A player already using an item
(drawing a bow, eating) gets no further either: Minecraft.handleKeybinds
drains the queued presses and throws them away before Minecraft.startUseItem
is reached. One level in, LocalPlayer.isHandsBusy stops a player who is
paddling a boat.
Inside, after LocalPlayer.isHandsBusy, the hands are tried in the order
InteractionHand.MAIN_HAND then InteractionHand.OFF_HAND. Each hand’s
stack must pass ItemStack.isItemEnabled — a disabled item aborts the whole
loop, not just its own hand — and then, for a BlockHitResult, the hand goes
to MultiPlayerGameMode.useItemOn. A InteractionResult.Success or an
InteractionResult.Fail ends the loop; only an InteractionResult.Pass
falls through to MultiPlayerGameMode.useItem (right-click air,
ServerboundUseItemPacket) and then to the off hand. The door returns
success on the main hand, so the off hand is never asked.
MultiPlayerGameMode.useItemOn sends a ServerboundSetCarriedItemPacket
first if the hotbar selection has moved
(MultiPlayerGameMode.ensureHasSentCarriedItem), refuses outright if the
target is outside the world border, and otherwise opens the prediction with
MultiPlayerGameMode.startPrediction, which allocates sequence n, runs the
whole client-side interaction inside it, and sends the
ServerboundUseItemOnPacket the interaction returned. The ledger that
prediction writes into belongs to the level rather than to the game mode:
ClientLevel.setBlock hands every state it overwrites while predicting to a
BlockStatePredictionHandler, so both halves of the door are recorded, and
both are restored together if the receipt arrives with the entries unchanged.
Block, then empty hand, then item
MultiPlayerGameMode.performUseItemOn and ServerPlayerGameMode.useItemOn
run the same three-step order. First the block is offered the item:
BlockBehaviour.BlockStateBase.useItemOn, whose BlockBehaviour.useItemOn
default answers InteractionResult.TRY_WITH_EMPTY_HAND. That sentinel — and
only when the hand is InteractionHand.MAIN_HAND — routes to
BlockBehaviour.BlockStateBase.useWithoutItem, whose
BlockBehaviour.useWithoutItem default is InteractionResult.PASS. If the
block consumed nothing, the item gets its turn through ItemStack.useOn,
provided the stack is non-empty and not held back by
ItemCooldowns.isOnCooldown. Sneaking skips the first two steps, but only
when some hand holds something: the guard is
Player.isSecondaryUseActive and a non-empty main or off hand, so an
empty-handed sneak still opens the door.
Three things differ between the two copies, and none of them is the inner
order. The server tests the block’s BlockBehaviour.requiredFeatures through
FeatureElement.isEnabled as its very first statement — a feature flag
narrowing the registry the click may reach (identifiers and
registries) — while the client
tests the same thing through ClientPacketListener.isFeatureEnabled inside
the not-sneaking branch. A spectator gets a flat
InteractionResult.CONSUME on the client, but on the server is routed to
BlockBehaviour.BlockStateBase.getMenuProvider and may end up with an open
container. And the advancement triggers exist only on the server:
CriteriaTriggers.ITEM_USED_ON_BLOCK when the item did it,
CriteriaTriggers.DEFAULT_BLOCK_USE when the empty-hand hook did, and
CriteriaTriggers.ANY_BLOCK_USE from the packet handler for anything that
consumed.
Those two hooks are how every right-clickable block in the game is written, and
the split between them is lopsided: 25 block classes override
BlockBehaviour.useItemOn, the ones that care what you are holding —
ComposterBlock taking something compostable, LecternBlock taking a book,
ChiseledBookShelfBlock reading the hit vector to pick a slot,
JukeboxBlock, CakeBlock, RespawnAnchorBlock — while 52 override
BlockBehaviour.useWithoutItem, the ones that answer an empty hand, of which
the door is one. Twelve override both, all six just named among them. A block
that does neither leaves the click to the item in the hand.
That is the whole family, and this door is an instance of it rather than a
special case.
The result is the vocabulary the whole pipeline turns on.
InteractionResult is a sealed interface of four records —
InteractionResult.Success, InteractionResult.Fail,
InteractionResult.Pass and InteractionResult.TryEmptyHandInteraction —
and the swing is part of it, not a separate decision.
InteractionResult.SUCCESS, InteractionResult.SUCCESS_SERVER and
InteractionResult.CONSUME are all InteractionResult.Success values
differing only in InteractionResult.SwingSource: the client animates at
once and the server animates for everyone else, the server animates for
everyone including the clicker, or nobody does. InteractionResult.consumesAction
is what both copies of the inner order test, and the server asks
InteractionResult.Success.shouldSwing before it swings; the client’s hand loop in Minecraft.startUseItem never asks it, and matches on
the record types instead. InteractionResult.Success carries two more answers
besides —
InteractionResult.Success.wasItemInteraction, which decides whether
Stats.ITEM_USED is awarded, and
InteractionResult.Success.heldItemTransformedTo, which both game modes use
to swap the stack the hand ends up holding.
The door writes ten
DoorBlock.useWithoutItem asks BlockSetType.canOpenByHand and, if the
answer is no, returns InteractionResult.PASS and lets the item try. For oak
it is yes: StateHolder.cycle flips DoorBlock.OPEN, and Level.setBlock
is called with flags 10 — Block.UPDATE_CLIENTS and
Block.UPDATE_IMMEDIATE, with Block.UPDATE_NEIGHBORS clear. Then
DoorBlock.playSound, LevelAccessor.gameEvent with GameEvent.BLOCK_OPEN or
GameEvent.BLOCK_CLOSE (posting is a nested loop inside the caller’s own
method), and
InteractionResult.SUCCESS. Exactly this code runs on both sides.
Ten is the whole story of the page, so it is worth spelling out as bits: 2 +
8, with 1 absent. Bit 2 is Block.UPDATE_CLIENTS and it broadcasts. Bit
8 is Block.UPDATE_IMMEDIATE, and on the client LevelExtractor.blockChanged
reads it not as immediate but as a player did this, which can buy the
section a priority remesh — LevelRenderer acts on that mark only when the
Chunk Builder option is set to prioritise nearby or player-affected
sections, which the fancy and fabulous graphics presets do, and fancy is the
preset a new client starts on. Bit 1
is Block.UPDATE_NEIGHBORS, and because it is absent Level.setBlock never
reaches its neighbour fan-out — which on the client would be a no-op anyway.
Nothing sets bit 16, Block.UPDATE_KNOWN_SHAPE, and that omission is what the
shape channel’s section below is about. What the flags then feed, and the rest of what a write
does, is the pair of flowcharts on blocks and
states; everything below is
the part of it the door walks.
The sound only you hear
DoorBlock.playSound passes the clicking player as the except entity, and
the two sides read that word oppositely. ClientLevel.playSeededSound plays
the sound only when the except entity is the local player;
ServerLevel.playSeededSound broadcasts a ClientboundSoundPacket to
everyone in range but them. So you hear your own prediction and never the
server’s copy — and since each side draws its own pitch from its own
Level.getRandom and its own seed from Level.soundSeedGenerator, your door
is genuinely a different sound from the one your friend heard. Every deferral
in this part that is not predicted does the opposite: a lever passes a null
except entity, so the clicker hears the server’s packet like everybody else
(signal and dust).
The shape channel, which both sides run
With Block.UPDATE_KNOWN_SHAPE clear and the update limit still positive,
the tail of Level.setBlock calls
BlockBehaviour.BlockStateBase.updateNeighbourShapes, which walks all six of
BlockBehaviour.UPDATE_SHAPE_ORDER — west, east, north, south, down, up —
and asks each neighbour, one top-level cascade at a time, whether it still
fits. Note which direction travels: for the block above, the level is handed
Direction.DOWN, the direction pointing from that neighbour back at the
door. Each hop costs one from the limit, so the upper half is written at 511
and asks its own neighbours at 510.
That is why the door is predictable and the redstone lamp is not: the shape
channel is the half of the update machinery a ClientLevel genuinely runs,
and the neighbour channel is the half whose methods are empty there (blocks
and states).
DoorBlock.updateShape answers four of its six callers with the state it was
given: the whole method is behind a test for the vertical axis, so the four
horizontals fall through to BlockBehaviour.updateShape, which returns the
state unchanged. The other two directions are where the door lives, and
besides leaving the state as it is there are three outcomes between them. Asked from the matching vertical direction — up
for a lower half, down for an upper — it returns the neighbour’s own state
with DoorBlock.HALF swapped to its own, so open, facing, hinge and
powered are copied wholesale, which is why the top half is already open by
the time it is written. Asked from that same direction when the neighbour is
not the other half, it returns Blocks.AIR. And a lower half asked from
Direction.DOWN returns air when DoorBlock.canSurvive fails — the block
beneath must be face-sturdy upward. Block.updateOrDestroy then compares: a
different non-air state becomes a Level.setBlock at the inherited limit, and
air becomes a Level.destroyBlock — which the hub shows is the one
branch of a shape update the client does not run (blocks and
states). That is the whole of
“break the bottom and the top pops”.
The updater underneath: a stack, drained depth-first
Every Level builds one Level.neighborUpdater, a
CollectingNeighborUpdater, in its constructor — on the client too. Requests
arrive as four small implementations of
CollectingNeighborUpdater.NeighborUpdates, three of them records:
CollectingNeighborUpdater.ShapeUpdate for the door’s case,
CollectingNeighborUpdater.SimpleNeighborUpdate and
CollectingNeighborUpdater.FullNeighborUpdate for a single neighbour, and
CollectingNeighborUpdater.MultiNeighborUpdate, one request that walks up to
six directions in NeighborUpdater.UPDATE_ORDER — the neighbour channel’s own
order, and not the shape one this door walks.
CollectingNeighborUpdater.addAndRun decides where a request goes by whether
a cascade is already running. The first one is pushed on
CollectingNeighborUpdater.stack and drained immediately by
CollectingNeighborUpdater.runUpdates; anything requested from inside a
running hook lands in CollectingNeighborUpdater.addedThisLayer and is
pushed on top of the stack before the current record’s remaining work, so the
drain is depth-first — a cascade finishes its children before its siblings.
NeighborUpdater.executeShapeUpdate and NeighborUpdater.executeUpdate do
the actual calls and wrap any throw in a crash report.
The chain limit is coarser than it looks and gentler than folklore says.
CollectingNeighborUpdater.count increments once per request, not per
depth level and not per block touched — a
CollectingNeighborUpdater.MultiNeighborUpdate is one
request that can expand to six calls — and past
CollectingNeighborUpdater.maxChainedNeighborUpdates further requests are
silently dropped after a single logged error, never a crash. The count is
reset when the outermost cascade unwinds, so the budget is per top-level
cascade. It is not a game rule: it is the max-chained-neighbor-updates
line in server.properties
(DedicatedServerProperties.maxChainedNeighborUpdates, default one million,
read through DedicatedServer.getMaxChainedNeighborUpdates), with
MinecraftServer.getMaxChainedNeighborUpdates hard-coding the same number
for the integrated server and ClientLevel passing the literal. Keep it
distinct from Block.UPDATE_LIMIT, the 512 that bounds nested writes: that
one counts recursion depth and is what the door’s 511 and 510 come from.
The gate list, and what each refusal answers with
ServerGamePacketListenerImpl.handleUseItemOn tests in this order, and the
interesting column is the second one — the refusals do not answer alike, and
one of them lies. The reach the third gate measures is an attribute, and
players have two of them — one for blocks and a shorter one for entities
(player anatomy).
| the gate | what the client gets when it fails |
|---|---|
ServerGamePacketListenerImpl.hasClientLoaded | nothing, not even the receipt |
ItemStack.isItemEnabled on the held stack | nothing |
Player.isWithinBlockInteractionRange, with 1.0 of slack | nothing |
| the hit location lying within one block of the clicked block’s centre on every axis — a 2×2×2 box, not the block | nothing, plus a server-side log line naming the player |
above LevelHeightAccessor.getMaxY or below LevelHeightAccessor.getMinY | ServerPlayer.sendBuildLimitMessage — an action-bar line, and no block update |
MinecraftServer.isUnderSpawnProtection | ServerPlayer.sendSpawnProtectionMessage, plus both block updates |
a pending teleport, or ServerLevel.mayInteract refusing for the world border | ServerPlayer.sendBuildLimitMessage — you are told you are building too high, whatever the real reason — plus both block updates |
| everything passed | ServerPlayerGameMode.useItemOn runs, plus both block updates |
The two block updates are a ClientboundBlockUpdatePacket for the clicked
position and one for the block on its clicked face, sent whatever the
interaction returned — and they sit inside the branch below the build-height
test, which is why a click that was out of reach or off the block is answered
with silence while a click into spawn protection is answered with the
truth. The door’s other half is in neither: it reaches the client with
everyone else’s copy, through ChunkHolder.broadcastChanges, which turns two
changed positions in one section into a single
ClientboundSectionBlocksUpdatePacket (and into two
ClientboundBlockUpdatePackets when the halves straddle a section boundary).
ServerGamePacketListenerImpl.ackBlockChangesUpTo was called after the first
gate and before the rest, and ServerGamePacketListenerImpl.tick emits the receipt when
MinecraftServer.tickChildren reaches connections — after the levels have
already broadcast (the server tick).
The other half of the same lecture
Left-click is this contract with a different pipeline:
Minecraft.startAttack reaches MultiPlayerGameMode.startDestroyBlock, which
opens its own prediction and sends a ServerboundPlayerActionPacket instead,
and the block hook is BlockBehaviour.BlockStateBase.attack. Nothing about the
ledger changes; everything about the clock does, because breaking the next
page’s stone takes eight ticks where opening takes none. Block breaking
takes it from there, and the two pages are deliberately the same shape.
Questions players ask
Why can’t I open an iron door by hand? Because
DoorBlock.useWithoutItem’s first question is BlockSetType.canOpenByHand,
false on BlockSetType.IRON and BlockSetType.GOLD and true on
BlockSetType.COPPER. Nothing on this path reads
BlockTags.WOODEN_DOORS — the copper door proves it, since it opens by hand
and is not in that tag. The tag only feeds other tags, the axe’s and the
doors mobs may open among them. Brain-driven mobs ask that one:
InteractWithDoor reads BlockTags.MOB_INTERACTABLE_DOORS, the wooden doors
and the copper ones, while the older goals read
DoorBlock.isWoodenDoor, which is BlockSetType.canOpenByHand under
another name.
Why does opening a door lag a busy server when nothing is powered?
Because the write still reaches ServerLevel.sendBlockUpdated, which
compares the old and new collision shapes and, when they differ, walks every
navigating mob in the level asking whether the change is worth a re-route —
the one place the world pushes back on pathfinding, and unbounded by distance
(pathfinding).
A door changes shape every time it moves. None of that exists on the client,
which is one more reason your half of the click is the fast half.
Where to look
The click comes in at Minecraft.handleKeybinds and
Minecraft.startUseItem, reaches the level through
MultiPlayerGameMode.useItemOn and MultiPlayerGameMode.performUseItemOn,
and is answered in InteractionResult. The two block hooks a state offers are
BlockBehaviour.BlockStateBase.useItemOn and
BlockBehaviour.BlockStateBase.useWithoutItem, and DoorBlock.useWithoutItem
is this door’s. Level.setBlock and
BlockBehaviour.BlockStateBase.updateNeighbourShapes are the write and the
shape cascade, CollectingNeighborUpdater.addAndRun the queue underneath it,
and DoorBlock.updateShape with Block.updateOrDestroy is where the top half
is decided. On the server, start at
ServerGamePacketListenerImpl.handleUseItemOn for the gates and
ChunkHolder.broadcastChanges for how the other half reaches everyone else.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.