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
| class | what it decides | thread |
|---|---|---|
BlockEventData | the record itself: a position, a block, and two ints | a record, no thread |
ServerLevel | that a block event is queued rather than run, and the one phase per tick in which the queue drains | Server |
Level | that on any other level — which in practice means ClientLevel — a block event runs immediately | Render |
PistonBaseBlock | whether to extend, which of three events to raise, and — at the drain, having re-checked — what actually moves | Server, then the client’s copy |
PistonStructureResolver | the set of blocks that move and the set that is destroyed, or a flat refusal | either side, allocated per attempt |
MovingPistonBlock | the placeholder state that occupies a position during the motion | either side |
PistonMovingBlockEntity | the two ticks of motion, the entities shoved along, and what is left behind at the end | both sides, in the block-entity phase |
PistonHeadBlock | the arm once the motion is over, and forwarding neighbour updates back to the base | Server |
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.processPacketsAndTickdrains the queued packets and then callsMinecraftServer.tickServerin 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.runBlockEventsdrains 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
PistonMovingBlockEntityis one step short of this group: it raises no event itself, but the neighbour bit of its landing write can reach a neighbouring piston, whosePistonBaseBlock.checkIfExtendthen queues one for next tick. - In a chunk that is not block-ticking — parked. Such an event goes to
ServerLevel.blockEventsToRescheduleand 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.
| what | written by | flags | the bits that matter |
|---|---|---|---|
| each moving block’s destination, and the arm | PistonBaseBlock.moveBlocks | 324 | Block.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 pulls | PistonBaseBlock.moveBlocks | 276 | the same three bits again, with Block.UPDATE_KNOWN_SHAPE in place of the piston bit — also not sent |
| a vacated position, set to air | PistonBaseBlock.moveBlocks | 82 | Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_KNOWN_SHAPE, Block.UPDATE_CLIENTS — this one is sent |
| a destroyed block, set to air | PistonBaseBlock.moveBlocks | 18 | Block.UPDATE_KNOWN_SHAPE, Block.UPDATE_CLIENTS |
| the piston base, now extended | PistonBaseBlock.triggerEvent | 67 | Block.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.