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

Pistons and block events

Verified against Minecraft 26.2 · Part V · A powered piston pushes one stone block, and the client is never told where the moving blocks are.

A piston cannot act when it is asked. A repeater told about a change books a turn in the appointment book and a wire recomputes itself on the spot; PistonBaseBlock.checkIfExtend does neither. It appends a four-value record to a set on the level and returns, and the push happens later, in a phase of the level tick named for exactly this. What comes out the other side is stranger still: no block update is ever sent for the moving blocks. The placeholders the server writes carry Block.UPDATE_CLIENTS deliberately clear, so nothing incremental is generated for them, and the copy on your screen exists only because your client re-ran PistonBaseBlock.moveBlocks itself against its own world, off a single ClientboundBlockEventPacket. It is not a prediction that gets confirmed. Nothing checks that the two animations agree; if that one packet is lost you watch nothing move, and only the landing write puts the block where everyone else already sees it.

The cast

classwhat it decidesthread
BlockEventDatathe record itself: a position, a block, and two intsa record, no thread
ServerLevelthat a block event is queued rather than run, and the one phase per tick in which the queue drainsServer
Levelthat on any other level — which in practice means ClientLevel — a block event runs immediatelyRender
PistonBaseBlockwhether to extend, which of three events to raise, and — at the drain, having re-checked — what actually movesServer, then the client’s copy
PistonStructureResolverthe set of blocks that move and the set that is destroyed, or a flat refusaleither side, allocated per attempt
MovingPistonBlockthe placeholder state that occupies a position during the motioneither side
PistonMovingBlockEntitythe two ticks of motion, the entities shoved along, and what is left behind at the endboth sides, in the block-entity phase
PistonHeadBlockthe arm once the motion is over, and forwarding neighbour updates back to the baseServer

The queue, and which tick it drains in

Level.blockEvent runs BlockBehaviour.BlockStateBase.triggerEvent on the spot. ServerLevel.blockEvent overrides it to add a BlockEventData to ServerLevel.blockEvents and return. That single override is the whole mechanism, and it is the reason the two sides of a piston behave so differently: the server always defers, the client never does.

The drain is ServerLevel.runBlockEvents, in the blockEvents section of ServerLevel.tick, after tickPending and chunkSource and before entities (the level tick). It is worth being exact about what that timing means, because “a block event is a tick late” is only sometimes true:

  • Queued by a packet handler — the same tick. MinecraftServer.processPacketsAndTick drains the queued packets and then calls MinecraftServer.tickServer in the same lap (the server tick), so a lever a player flipped is handled before the level ticks at all, and the event it raised is drained in that same level tick.
  • Queued by a scheduled tick — the same tick. tickPending runs before blockEvents, so a repeater firing into a piston is also drained immediately (scheduled ticks).
  • Queued by another block event — the same tick. ServerLevel.runBlockEvents drains until the set is empty, so an event raised while the drain is running is taken by the same drain.
  • Queued by an entity or a block entity — the next tick. Those phases run after blockEvents, so anything they raise waits a full lap. A landing PistonMovingBlockEntity is one step short of this group: it raises no event itself, but its Level.neighborChanged can reach a neighbouring piston, whose PistonBaseBlock.checkIfExtend then queues one for next tick.
  • In a chunk that is not block-ticking — parked. Such an event goes to ServerLevel.blockEventsToReschedule and is re-added after the loop, so it is retried next tick rather than dropped.

ServerLevel.blockEvents is a linked hash set, so two identical events raised in one tick collapse into one. And ServerLevel.doBlockEvent re-reads the position and runs the event only if the block there is still the block the event named — the same promise a scheduled tick makes. When BlockBehaviour.BlockStateBase.triggerEvent returns true, and only then, a ClientboundBlockEventPacket goes to every player within 64 blocks.

The piston is the mechanism’s most demanding customer but not its only one. Three blocks raise events directly — PistonBaseBlock, NoteBlock and PotentSulfurBlock — and seven block entities raise their own, reaching themselves back through BaseEntityBlock.triggerEvent, which is how a chest lid, an ender chest, a shulker box, a bell, a decorated pot, a spawner and an end gateway all get animated on clients that own no copy of their state. ComparatorBlock is the odd one out and worth a moment: it overrides BlockBehaviour.BlockStateBase.triggerEvent to forward to its block entity, but ComparatorBlockEntity overrides nothing and nothing anywhere raises a comparator event, so the override is dead in both directions.

One push, tick by tick

sequenceDiagram
    participant SL as ServerLevel
    participant PBB as PistonBaseBlock
    participant PSR as PistonStructureResolver
    participant PMBE as PistonMovingBlockEntity
    participant CPL as ClientPacketListener
    participant CL as ClientLevel
    Note over SL,CL: tick N, a packet handler, before the level ticks
    SL->>PBB: neighborChanged, so checkIfExtend
    PBB->>PBB: getNeighborSignal finds the wire, and EXTENDED is false
    PBB->>PSR: resolve, as a dry run. Stone is pushable, air beyond it
    PBB->>SL: blockEvent TRIGGER EXTEND with the facing packed in. Nothing moves
    Note over SL,CL: tick N, blockEvents phase, still the same tick
    SL->>PBB: doBlockEvent re-reads the block, then triggerEvent
    PBB->>PBB: getNeighborSignal again. A pulse shorter than the gap dies here
    PBB->>PSR: resolve a second time, for real
    PBB->>SL: moveBlocks writes MOVING PISTON placeholders at flags 324, one for the stone and one for the arm
    PBB->>SL: then triggerEvent itself writes the extended base at flags 67
    SL-->>CPL: ClientboundBlockEventPacket within 64 blocks, and a sound packet
    CPL->>CL: Level.blockEvent runs immediately on the client
    CL->>PBB: the same triggerEvent, the same moveBlocks, against the client's world
    Note over SL,CL: tick N, blockEntities phase, and tick N plus 1, both sides
    PMBE->>PMBE: progress 0 to 0.5 to 1, moveCollidedEntities under NOCLIP
    Note over SL,CL: tick N plus 2, blockEntities phase
    PMBE->>SL: the placeholder becomes the real stone at flags 67, and the arm a PISTON HEAD

How a piston decides, and the line that cannot fire

PistonBaseBlock.getNeighborSignal is the whole of quasi-connectivity, and it is one short method. It asks SignalGetter.hasSignal at all six neighbours except the one it faces; then, if none of those answered, it repeats the same question for five of the neighbours of the position directly above the piston, skipping Direction.DOWN because that would only read the piston again. The piston is not the only block that reaches up like this — DispenserBlock.neighborChanged asks SignalGetter.hasNeighborSignal at its own position or at the one above it, and DropperBlock inherits that, and DoorBlock.getStateForPlacement does the same — but each of the three writes the reach out by hand, and no other block in the game has it. That is why quasi-connectivity is a short list of block-by-block quirks rather than a redstone rule. What signal means, and how the wire beside the piston comes to be connected to it at all, is signal and dust.

Between the two loops sits a third question — SignalGetter.hasSignal at the piston’s own position, looking down — and it can never return true. SignalGetter.getSignal consults the strong power pushed into a position only when the block there is a redstone conductor, and Blocks.pistonProperties declares a piston never to be one; PistonBaseBlock overrides no signal method of its own, so its weak answer is zero. Strong power reaches a piston the ordinary way, through its conducting neighbours, in the first loop. The middle call is dead.

PistonBaseBlock.checkIfExtend turns the answer into one of three events. Powered and not extended raises PistonBaseBlock.TRIGGER_EXTEND — but only if a dry-run PistonStructureResolver.resolve succeeds first, so a piston with an immovable wall in front of it queues nothing at all. Unpowered and extended raises PistonBaseBlock.TRIGGER_CONTRACT, or PistonBaseBlock.TRIGGER_DROP when the extension it would retract is still in flight: the block two ahead is still a Blocks.MOVING_PISTON facing the same way and extending, and either its progress is under half, or it was ticked this very game tick, or ServerLevel.isHandlingTick says the level is still inside the window that closes when the block-event drain ends.

What moves, and what is simply gone

PistonStructureResolver runs twice per push — once as PistonBaseBlock.checkIfExtend’s dry run, once for real inside PistonBaseBlock.moveBlocks — and produces two lists, PistonStructureResolver.toPush and PistonStructureResolver.toDestroy. PistonStructureResolver.addBlockLine walks forward from the piston until it runs out of blocks — and backwards along the same axis while the block behind is sticky — refusing the whole push, not merely stopping, when it meets something unpushable or runs past twelve. Twelve is a literal at each of the three tests; PistonStructureResolver.MAX_PUSH_DEPTH holds it and is read nowhere. PistonStructureResolver.addBranchingBlocks follows slime and honey sideways, with PistonStructureResolver.canStickToEachOther refusing the one pairing everybody tests first — slime against honey does not stick.

PistonBaseBlock.isPushable is the per-block veto and it is a longer list than folklore suggests: outside the build height or the world border, obsidian and its three relatives, a block whose destroy speed is −1, a PushReaction of PushReaction.BLOCK, a PushReaction.DESTROY where the caller did not allow destruction, a PushReaction.PUSH_ONLY being moved the wrong way, an already-extended piston, and — the clause that explains the most — anything with a block entity. A push straight down at the bottom of the world or straight up at the top is refused too, and a piston itself skips the destroy-speed and push-reaction tests entirely. A chest cannot be pushed because it has a block entity; that PistonMovingBlockEntity has nowhere to keep one is the reading of the code that makes sense of the clause, not something the code says.

The write nobody is told about

A push writes five kinds of position, and their flag words are the page’s hook made concrete. Four are PistonBaseBlock.moveBlocks’s; the fifth is written by PistonBaseBlock.triggerEvent after the other four. Which bit does what is blocks and states.

whatwritten byflagsthe bits that matter
each moving block’s destination, and the armPistonBaseBlock.moveBlocks324Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS, Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_INVISIBLE — and no Block.UPDATE_CLIENTS
an old arm cleared on a retractionPistonBaseBlock.moveBlocks276the same three bits again, with Block.UPDATE_KNOWN_SHAPE in place of the piston bit — also not sent
a vacated position, set to airPistonBaseBlock.moveBlocks82Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_KNOWN_SHAPE, Block.UPDATE_CLIENTS — this one is sent
a destroyed block, set to airPistonBaseBlock.moveBlocks18Block.UPDATE_KNOWN_SHAPE, Block.UPDATE_CLIENTS
the piston base, now extendedPistonBaseBlock.triggerEvent67Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_CLIENTS, Block.UPDATE_NEIGHBORS

The vacated row is rarer than it looks. PistonBaseBlock.moveBlocks starts with every pushed position marked for deletion and then unmarks each destination and, on an extension, the arm — so for a straight push down a line every origin is somebody’s destination and the set empties. In this page’s trace nothing is written at 82 at all: the client is told that the base is extended, and nothing about the two positions now holding placeholders. Each placeholder’s PistonMovingBlockEntity is injected by hand with Level.setBlockEntityMovingPistonBlock.newBlockEntity returns null, because a moving piston is never created by the ordinary block-entity path — and carries the real block as PistonMovingBlockEntity.movedState.

ClientPacketListener.handleBlockEvent hands the packet to the base Level.blockEvent, which runs PistonBaseBlock.triggerEvent immediately against the ClientLevel: the same resolver, the same placeholders, the same injected block entities. What the client’s copy does not do is the server’s-side-only work — no drops for a crushed block, no game event, no BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval — and, most audibly, no sound. PistonBaseBlock.triggerEvent passes a null except entity, and ClientLevel.playSeededSound plays a sound only when except is the local player — so the piston you hear is the server’s ClientboundSoundPacket, arriving beside the event. The particles of a crushed block are the mirror image: the level event that spawns them is raised inside PistonBaseBlock.moveBlocks on the client side only, and only for a block outside BlockTags.FIRE.

Two ticks of motion, and two ways to end

PistonMovingBlockEntity.tick runs in the block-entity phase on both sides and does one thing per tick: add 0.5 to PistonMovingBlockEntity.progress, after shoving whatever is in the swept slab with PistonMovingBlockEntity.moveCollidedEntities and dragging honey-stuck entities with PistonMovingBlockEntity.moveStuckEntities. The PistonMovingBlockEntity.NOCLIP thread-local is set around each entity’s own move — and holds the push Direction rather than a flag — so that a pushed entity may pass through the very block pushing it. PistonMovingBlockEntity.TICKS_TO_EXTEND is declared as 2, and the 0.5 is written as a literal — no reader of the constant survives the decompile.

The tick after progress reaches 1 is the landing. The entity is removed, and Block.updateFromNeighbourShapes re-fits the moved state to its new surroundings before it is written at flags 67, with a waterlogged property cleared if it survived the trip. The client holds five extra PistonMovingBlockEntity.deathTicks before doing the same, which is why the visual arrival lags the server’s slightly — and why it does not matter, since the server’s own write is broadcast anyway.

PistonMovingBlockEntity.finalTick is a different operation, not an early-exit form of that one, and the difference is what makes an interrupted extension clean up after itself. It writes at flags 3 rather than 67, and for the entity carrying the arm — the one with PistonMovingBlockEntity.isSourcePiston — it writes air instead of the moved state, so a retraction that catches its own extension in flight leaves nothing behind. That flag is set both on the arm’s placeholder and on the one a contracting piston writes at its own position. It is reached from PistonMovingBlockEntity.preRemoveSideEffects, and directly from PistonBaseBlock.triggerEvent’s contract branch.

Questions players ask

Is a piston really a tick late? The queue is not a fixed delay — it is a wait for one named phase. A lever flipped by a player and a repeater firing both land in the same tick the piston was told about, because packets and scheduled ticks are both handled before the blockEvents phase. What you see as the piston’s delay is two ticks of motion and a third for the landing, and the first of the three is the very tick the piston was told about: the placeholders’ tickers go straight into the level’s list, which the blockEntities phase then walks later in the same tick.

Why did my one-tick pulse move nothing at all? Because PistonBaseBlock.triggerEvent asks PistonBaseBlock.getNeighborSignal again at the drain, and a piston no longer powered simply returns false. The event is consumed, no blocks move, and no ClientboundBlockEventPacket is sent, so nobody sees anything happen either.

Why does a piston push a block that is powering it? Because the two questions are asked of different positions. PistonBaseBlock.getNeighborSignal deliberately skips the direction the piston faces when looking for power, so the block in front never counts as a source — and then reaches up a block, which is the only place in the game anything does.

Can a piston push a chest? No, and the reason is one clause in PistonBaseBlock.isPushable: anything with a block entity is refused. PistonMovingBlockEntity has a field for the moved state and none for a moved block entity, so there is nowhere to put a chest’s contents for the two ticks of the journey.

Why did the blocks stay put on my screen when everyone else saw them move? Because the placeholders are not synchronised. Your client built its copy by re-running the push from one ClientboundBlockEventPacket, and that packet is sent once, to players within 64 blocks, only if the server’s own BlockBehaviour.BlockStateBase.triggerEvent returned true. Nothing checks afterwards that the two animations agree; what does converge is the destination, because the landing write carries Block.UPDATE_CLIENTS and is broadcast like any other.

Where to look

Level.blockEvent · ServerLevel.blockEvent · ServerLevel.runBlockEvents · ServerLevel.doBlockEvent · BlockEventData · BlockBehaviour.BlockStateBase.triggerEvent · BaseEntityBlock.triggerEvent · PistonBaseBlock.checkIfExtend · PistonBaseBlock.getNeighborSignal · PistonBaseBlock.triggerEvent · PistonBaseBlock.moveBlocks · PistonBaseBlock.isPushable · PistonStructureResolver.resolve · PistonStructureResolver.addBlockLine · PistonStructureResolver.addBranchingBlocks · MovingPistonBlock.newMovingBlockEntity · PistonMovingBlockEntity.tick · PistonMovingBlockEntity.finalTick · PistonMovingBlockEntity.moveCollidedEntities · ClientPacketListener.handleBlockEvent


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