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.3 · 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: in a push that destroys nothing, nothing the push writes sends a block update for the moving blocks. The placeholders the server writes carry Block.UPDATE_CLIENTS 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 the neighbour bit of its landing write 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 pending at once 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.

Who else uses the channel

The piston is the mechanism’s most demanding customer but not its only one. Three blocks raise events directly — PistonBaseBlock, NoteBlock and PotentSulfurBlock, the geyser, whose event carries no data at all and exists only to have each side stamp its own game time on its block entity as the eruption’s start — and seven block entities raise their own, reaching themselves back through BaseEntityBlock.triggerEvent: ChestBlockEntity, EnderChestBlockEntity, ShulkerBoxBlockEntity, BellBlockEntity, DecoratedPotBlockEntity, SpawnerBlockEntity and TheEndGatewayBlockEntity (a chest lid, an ender chest, a shulker box, a bell, a decorated pot, a spawner and an end gateway), all animated on clients that own no copy of their state. The other forty-odd block entities in the game raise none, which is why a block event is a small channel rather than a general one. ComparatorBlock is the odd one out and worth a moment: it overrides BlockBehaviour.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

Six flag words go past on this page, and four are worth having in hand: 324 is skip the block entity’s side effects, moved by piston, invisible; 276 swaps the piston bit for known shape; 67 is moved by piston, clients, neighbours; and 3 is the ordinary neighbours and clients. Of the four, only 67 and 3 tell a client anything. The table in the write nobody is told about has the first three row by row, with 82 and 18, which are sent too, the other way to end has the fourth, and the bit names are the catalogue’s (block update flags).

sequenceDiagram
    participant SL as ServerLevel
    participant PBB as PistonBaseBlock
    participant PSR as PistonStructure<br/>Resolver
    participant PMBE as PistonMoving<br/>BlockEntity
    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, EXTENDED is false
    PBB->>PSR: resolve, as a dry run
    PSR-->>PBB: the stone is pushable, air beyond it
    PBB->>SL: blockEvent, TRIGGER EXTEND with the facing packed in
    Note over SL,CL: tick N, block-events phase, still the same tick
    SL->>SL: doBlockEvent re-reads the position
    SL->>PBB: triggerEvent
    PBB->>PBB: getNeighborSignal, a second time
    opt still powered — a pulse shorter than the gap dies here
        PBB->>PSR: resolve, for real this time
        PBB->>SL: setBlock, two MOVING PISTON placeholders at flags 324
        PBB->>SL: setBlockEntity, one PistonMovingBlockEntity per placeholder
        PBB->>SL: setBlock, the extended base at flags 67
        SL-->>CPL: ClientboundBlockEventPacket within 64 blocks, and a sound packet
        CPL->>CL: blockEvent, run at once rather than queued
        CL->>PBB: triggerEvent again, against the client's world
    end
    Note over SL,CL: tick N and tick N plus 1, block-entities phase, both sides
    PMBE->>PMBE: progress 0 to 0.5 to 1, moveCollidedEntities under NOCLIP
    Note over SL,CL: tick N plus 2 on the server, five PistonMovingBlockEntity.deathTicks later on the client
    PMBE->>SL: setBlock, the real stone and a PISTON HEAD, flags 67

One extension, from the redstone update to the landing write. The bracket is the whole of the point: everything inside it, the block-event packet the client re-runs included, happens only if the wire is still powered when the block event is drained, later in the tick that queued it.

How a piston decides, and the line that cannot fire

Quasi-connectivity is two loops, not a rule

PistonBaseBlock.getNeighborSignal is the whole of quasi-connectivity, and it is one short method. Its first loop asks SignalGetter.hasSignal at all six neighbours except the one it faces; then, if none of those answered, a second loop 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, but the family is tiny: DispenserBlock.neighborChanged asks SignalGetter.hasNeighborSignal at its own position or at the one above it, and DropperBlock has the behaviour too, by extending DispenserBlock without overriding that method — so three classes reach up and only two write it down, and no other block in the game does either. (DoorBlock asks the same pair, but for a door the position above is its own upper half.) 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.

The call between the loops is dead

Between those two loops sits a third question — SignalGetter.hasSignal at the piston’s own position, looking down — and it can never return true. A position answers with strong power only if the block standing there is a redstone conductor (signal and dust), and Blocks.pistonProperties declares a piston never to be one; PistonBaseBlock overrides no signal method of its own either, so its weak answer is zero as well. Strong power reaches a piston the ordinary way, through its conducting neighbours, in the first loop. The middle call is dead.

Three events, and what the third one declines to do

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 opens at the top of the tick and closes when this drain ends (the level tick).

The two retraction events run the same branch of PistonBaseBlock.triggerEvent, and they differ in exactly one thing: a sticky piston contracting normally may drag the block two ahead back with it, and a drop may not — the pull is guarded on the event being PistonBaseBlock.TRIGGER_CONTRACT. A sticky piston whose extension is caught in flight leaves the block it was carrying where it stands: if the block is still moving at the drain, PistonMovingBlockEntity.finalTick sets it down in place of any pull, whichever the event, and if it has only just landed, the guard keeps a drop from pulling it back. That is the thing players discover by accident.

What moves, and what is simply gone

PistonStructureResolver runs at least twice per push on the server — a dry run each time PistonBaseBlock.checkIfExtend finds the piston powered and not extended, then once for real inside PistonBaseBlock.moveBlocks — and once more in the client’s copy, and produces two lists, PistonStructureResolver.toPush and PistonStructureResolver.toDestroy. PistonStructureResolver.addBlockLine walks backwards first, from the block it starts at and while the block it has reached is sticky, then forward until it runs out of blocks, refusing the whole push, not merely stopping, when the forward walk meets something unpushable or the blocks to move pass twelve in all; walking backwards, an unpushable block only ends the walk. Twelve is the number PistonStructureResolver.MAX_PUSH_DEPTH names, tested in three places. 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, a block whose destroy speed is −1, a PushReaction of PushReaction.IMMOVEABLE (which is how obsidian and fourteen other blocks, the anvils and the lodestone among them, say it, as data), a PushReaction.POPPED where the caller did not allow destruction, a PushReaction.PUSH being moved the wrong way, an already-extended piston, and — the clause that explains the most — anything else with a block entity that would not pop. 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

An extension and a sticky pull write five kinds of position between them, and their flag words are the page’s hook made concrete. Four are PistonBaseBlock.moveBlocks’s; the fifth, on an extension, is written by PistonBaseBlock.triggerEvent after PistonBaseBlock.moveBlocks returns. 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 when a sticky piston pullsPistonBaseBlock.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

A retraction also writes the base’s own placeholder at 276, from PistonBaseBlock.triggerEvent before any of these, and a piston that does not pull clears its arm with Level.removeBlock, at 3. In this page’s trace nothing is written at 82 at all — every origin down a straight push is somebody else’s destination, so the vacated set empties — and the reader should take the table’s point from the two rows that do fire here: 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, built by the static MovingPistonBlock.newMovingBlockEntity and installed with Level.setBlockEntity — MovingPistonBlock.newBlockEntity, the ordinary factory, returns null, because a moving piston is never created by the ordinary block-entity path (block entities) — and carries the real block as PistonMovingBlockEntity.movedState. That state is also the one thing about the moving blocks that does travel as data: PistonMovingBlockEntity overrides BlockEntity.getUpdateTag, so a player who loads the chunk mid-push receives the placeholder and its cargo in the chunk packet rather than as an update.

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, so nobody is excluded and there is no local prediction to hear: the piston you hear is the server’s ClientboundSoundPacket, arriving beside the event (what makes a sound). 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 (bar the few entities that ignore pistons) — PistonMath is the class that computes it — 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. The two ticks are the number PistonMovingBlockEntity.TICKS_TO_EXTEND names.

Note where those two ticks start. The placeholders are written during the blockEvents phase, and a ticker goes straight into the level’s flat list — so the blockEntities phase, later in the very same tick, already walks it. The motion’s first tick is the tick the piston was told about, not the one after, which is why a piston is not “a tick late” so much as three ticks long.

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 arm’s position becomes a real PistonHeadBlock, and that block earns its place in the cast by being the piece of the assembly that talks backwards once the motion is over: PistonHeadBlock.neighborChanged forwards the updates it receives to the base behind it whenever it has one, so power arriving at the head reaches the piston that owns it, and PistonHeadBlock.affectNeighborsAfterRemoval destroys that base when the head is broken, which is why an arm cannot be mined off a piston and left behind. The client holds five extra PistonMovingBlockEntity.deathTicks before writing the landing itself; on screen the block has already arrived, its progress having reached 1, and the server’s own write is broadcast anyway.

The other way to end

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 — plain neighbours and clients, without the moved-by-piston bit the ordinary landing carries — 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

Why did my zero-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 — which is what a pulse that ends before that tick’s blockEvents phase leaves. The event is consumed, no blocks move, and no ClientboundBlockEventPacket is sent, so nobody sees anything happen either.

Why does a redstone block in front of a piston not power it? Because the piston never asks there. 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 goes looking one block higher, where a redstone build would not think to check.

Where to look

The channel first: Level.blockEvent against ServerLevel.blockEvent is the one override the page turns on, ServerLevel.runBlockEvents and ServerLevel.doBlockEvent are the drain, and BlockBehaviour.BlockStateBase.triggerEvent is what a block is finally asked. Then the piston: PistonBaseBlock.checkIfExtend decides, PistonBaseBlock.getNeighborSignal is quasi-connectivity, PistonBaseBlock.triggerEvent runs at the drain and PistonBaseBlock.moveBlocks does the writing. PistonStructureResolver.resolve with PistonStructureResolver.addBlockLine is what moves and PistonBaseBlock.isPushable what does not. The motion is PistonMovingBlockEntity.tick and its two endings, PistonMovingBlockEntity.finalTick being the interesting one. ClientPacketListener.handleBlockEvent is the client’s entire share. BaseEntityBlock.triggerEvent and PistonMovingBlockEntity.moveCollidedEntities are doors this page only points at: the seven block entities that animate themselves, and what a push does to whatever is standing in the way.


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