Block entities
Verified against Minecraft 26.3 · Part V · A furnace smelts raw iron while nobody is watching, and the player who opens it learns the fire from a block state and the arrow from a menu — never from the block entity itself.
You drop raw iron in the top slot of a furnace, coal in the bottom, and walk
away. Two hundred ticks later there is an iron ingot in a box nobody is
looking at. A block state is one of a fixed table, shared by every position
that has it, so the moment a position needs something of its own — an
inventory, a timer, a name — it gets a BlockEntity: a plain object owned by
its chunk, keyed by its position, created by the block and destroyed with it.
The surprise is how little that object says for itself. A furnace tells
nobody anything: BlockEntity.getUpdatePacket returns null for it,
BlockEntity.setChanged sends nothing at all, and the two things a player
does see — the fire in the world and the arrow in the GUI — are a block
state and four ints from a menu, both of which arrive on the tick after
the smelting step that produced them, because block entities tick in the
level’s last content phase, after the broadcast and the players’ ticks have gone by (the
level tick).
The cast
| class | what it decides | thread |
|---|---|---|
BlockEntity | the position, the cached state, the components, and four defaults every subclass inherits: two for disk, and two for the network that both amount to say nothing | whichever thread owns the level |
BlockEntityType | which blocks the entity is legal on, and what constructs it | immutable once the registry is built |
EntityBlock | whether a block has an entity at all, which one, and which ticker per level | — |
LevelChunk | the position-to-entity map, the ticker wrapper per position, and create / keep / replace / remove on every write | the chunk’s owning thread |
Level | the flat list of tickers and the two gates over it | Server thread, or the Render thread |
AbstractFurnaceBlockEntity | three slots, four ints, a cached recipe check, and when the block’s lit state has to change | Server thread only — its ticker is null on the client |
ChunkHolder | which positions changed since the last drain, and the call to BlockEntity.getUpdatePacket for each broadcast position that has a block entity | Server thread, chunk-source phase |
FurnaceMenu | what an open screen is allowed to see of all that: three slots and four ints | Server thread, mirrored on the client |
A furnace tells nobody anything
BlockEntity has four hooks a subclass is expected to fill in, and they come
in two pairs. BlockEntity.saveAdditional and BlockEntity.loadAdditional are
the disk pair, and the base class’s versions do nothing because what it saves of its own, the id, the position and the components, is written by the shells around them. BlockEntity.getUpdateTag and
BlockEntity.getUpdatePacket are the network pair, and their emptiness is a
decision rather than an absence: the tag comes back empty and the packet comes
back null. The base class declines to be synced, and a subclass that wants
to be must say so.
Nineteen classes say so, and every one of them answers with
ClientboundBlockEntityDataPacket.create of itself: signs, banners, beacons,
skulls, spawners and trial spawners, conduits, end gateways, structure and
jigsaw blocks, campfires, decorated pots, vaults, shelves, brushable blocks,
creaking hearts, copper golem statues and the two test blocks. Counting those
declarations in the decompile and mapping each class onto the 49
registrations in BlockEntityTypes gives twenty synced types out of
forty-nine, because HangingSignBlockEntity is a type of its own that
inherits SignBlockEntity’s override and adds nothing.
The overriders of the packet and the overriders of the tag are not the same
list — there are nineteen of those too, and it is a different nineteen — and the
two classes that differ are instructive.
PistonMovingBlockEntity overrides BlockEntity.getUpdateTag but not the
packet, so its state travels only in a chunk send
(pistons and block events).
CopperGolemStatueBlockEntity overrides the packet but not the tag, so what
it broadcasts is the base class’s empty tag.
Everything else a client knows about a block entity (an operator’s query, a command block’s screen and the debug channel aside) it knows by consequence: the block state it can see, a menu it has been given, and a block event — the third channel, and the one that swings a chest lid without either side saying what is inside (pistons and block events). The trace below is what the first two cost.
One save hook, four ways out
Saving is a tree, not a chain, and the branch a caller picks decides how much
metadata rides along. Only BlockEntity.saveAdditional belongs to the
subclass; everything else is bookkeeping the base class adds.
| what runs | what it writes | who calls it |
|---|---|---|
BlockEntity.saveAdditional | the subclass’s own fields, and nothing else | nobody directly |
BlockEntity.saveCustomOnly | that alone | thirteen of the tag overrides, the pick-block path, /clone and a few others |
BlockEntity.saveWithoutMetadata | that plus components | the two below, plus BlockInput, the falling block and a few others |
BlockEntity.saveWithId | that plus id | a structure template, which records a relative position of its own, and the adventure-mode block check’s cache |
BlockEntity.saveWithFullMetadata | that plus id, x, y and z | LevelChunk.getBlockEntityNbtForSaving, /data, the block predicates, a text component’s block NBT and a few others |
Reading back is BlockEntity.loadWithComponents (fields plus components) or
BlockEntity.loadCustomOnly (fields only) over a ValueInput
(codecs, NBT and JSON) — but something
has to decide which class to construct first, and that cannot come through
a ValueInput, because no entity exists yet to own one. So
BlockEntity.loadStatic reads id off the raw CompoundTag with
BlockEntity.TYPE_CODEC, calls BlockEntityType.create, and only then wraps
the same tag in a ValueInput and loads it. Any of those three steps failing
logs and returns null, and the position has no entity until a plain read builds
a blank one from the block state.
The network joins that path at the end rather than reusing it whole:
ClientPacketListener.handleBlockEntityData never reads id — it asks for the
entity by position and type, which a plain read builds from the block state if
it is missing, and hands the tag to BlockEntity.loadWithComponents. There is no separate network
deserialiser. Where the chunk’s block_entities list is
written and read is chunk storage.
Create, keep, replace, remove
A block entity that appears because a block appeared is created and
destroyed inside one method: LevelChunk.setBlockState, after the section
write, the heightmaps and the light checks (what a write
does). That is the lifecycle
path, and it makes two decisions — but in the opposite order to the one this
section is named for, because the old entity has to go before
BlockBehaviour.BlockStateBase.onPlace runs and the new one is built after it.
Removal first, then creation. It is not the only way one
comes into being: a chunk arriving from disk builds its entities from saved
tags, one arriving from the network from its block states and then their tags,
and LevelChunk.getBlockEntity in its immediate
mode constructs a missing one on a plain read — which is the mode every
Level.getBlockEntity asks for.
Removal happens only when the block changed, the old state had an
entity, and the new state does not claim it through
BlockBehaviour.BlockStateBase.shouldChangedStateKeepBlockEntity — which
exactly two blocks in 26.3 override, CopperChestBlock and
CopperGolemStatueBlock, both keeping the entity when the old state was
another block of the same family, so oxidising or waxing a copper chest does
not empty it. Removal is two halves with different gates. The side effects —
BlockEntity.preRemoveSideEffects, which for anything implementing
Container drops the contents through Containers.dropContents — run only
on the server and only with Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS
clear. The bookkeeping, LevelChunk.removeBlockEntity, runs regardless —
though only the last of its steps is itself unconditional. The map entry going,
the game-event listener being unregistered, the debug view being told and the
entity being flagged removed all sit behind is this chunk loaded in a level,
which on the server a LevelChunk is not before its promotion finishes or once it is
unloading; the rebind of the ticker to LevelChunk.NULL_TICKER happens either
way.
Creation happens after BlockBehaviour.BlockStateBase.onPlace, and only
if the new state has a block entity and BlockBehaviour.BlockStateBase.onPlace
has not replaced the block. The chunk looks for
an existing one without creating it, and if what it finds does not pass
BlockEntity.isValidBlockState for the new state it logs a mismatched block
entity warning, removes it and builds a fresh one from
EntityBlock.newBlockEntity — the block’s own factory, not
BlockEntityType.create. Only a surviving match is kept, with its cached
state refreshed and LevelChunk.updateBlockEntityTicker re-asking the block
for a ticker. That is why flipping a furnace’s lit property costs almost
nothing: same block, valid state, same object — a fresh
LevelChunk.BoundTickingBlockEntity rebound into the wrapper is the whole of
the expense.
Chunk load and unload use the ends of the same machinery.
ChunkStatusTasks.full runs LevelChunk.runPostLoad to turn the saved tags
into entities, then LevelChunk.setLoaded and
LevelChunk.registerAllBlockEntitiesAfterLevelLoad, which attaches listeners
and tickers to entities built before the chunk belonged to a level.
ServerLevel.unload calls LevelChunk.clearAllBlockEntities: every entity
flagged removed, every ticker pointed at LevelChunk.NULL_TICKER.
Two hundred ticks nobody watches
sequenceDiagram
participant SL as ServerLevel
participant LC as LevelChunk
participant AFBE as AbstractFurnace<br/>BlockEntity
participant CH as ChunkHolder
participant FM as FurnaceMenu
participant CPL as ClientPacketListener
Note over SL,CPL: tick N, chunk-source phase: the broadcast drain runs now, with nothing yet to send
Note over SL,CPL: tick N, entities phase: the players' menus, with nothing new yet
Note over SL,CPL: tick N, block-entities phase, the level's last content phase
SL->>LC: LevelChunk.<br/>BoundTickingBlockEntity.tick
LC->>AFBE: serverTick, quickCheck finds the smelting recipe
Note over AFBE: fuel consumed, both lit fields take 1600, the cooking timer at one
AFBE->>SL: setBlock, LIT true under flags 3
SL->>LC: setBlockState, same block, the entity kept and the ticker rebound
SL->>CH: blockChanged, which only marks the holder dirty
AFBE->>SL: updateNeighbourForOutputSignal, the comparator poke
Note over SL,CPL: tick N plus 1, chunk-source phase, the broadcast drain
CH->>CPL: ClientboundBlockUpdatePacket, the fire appears
Note over CH: BlockEntity.<br/>getUpdatePacket<br/>answers nothing
Note over SL,CPL: tick N plus 1, entities phase, the players tick
Note over CH,FM: AbstractContainerMenu.<br/>broadcastChanges checks the four data slots
FM->>CPL: ClientboundContainerSet<br/>DataPacket per changed slot
Two consequences of one smelting tick, both a tick late because the block entity ticks in the level’s last content phase, after the drain that turns a dirty holder into a packet and after the players whose ticks reconcile the menu. The two routes out are the block state and the menu’s four ints, and they arrive by different machinery.
The furnace’s ticker is handed out by AbstractFurnaceBlock.createFurnaceTicker
only when the level is a ServerLevel — on the client it is null, so no
furnace anywhere ever ticks there. AbstractFurnaceBlockEntity.serverTick is
therefore the whole of smelting: burn down
AbstractFurnaceBlockEntity.litTimeRemaining, ask
AbstractFurnaceBlockEntity.quickCheck (a RecipeManager.CachedCheck, which
retries last tick’s match before scanning the type) for a recipe on the input
slot, check that the result slot can take the output, and, if the fire is out
but fuel is present, light it: both lit fields take
AbstractFurnaceBlockEntity.getBurnDuration for that item — what its
DataComponents.COOKING_FUEL component resolves to in this furnace, 1600 for
coal — and one fuel item is consumed. Then
AbstractFurnaceBlockEntity.cookingTimer advances by one. Where the recipe
comes from is recipes.
The two hundred is the recipe’s number rather than the furnace’s:
AbstractFurnaceBlockEntity.cookingTotalTime holds the recipe’s cookingtime
— 200 in raw iron’s recipe, a field every cooking recipe must name —
divided by the speed multiplier the fuel sets, which is one in a furnace. Nor is the timer a stopwatch that
merely stops. With the fire out and either slot empty it runs backwards, two a
tick, clamped at zero, so an interrupted smelt loses its progress in half the
time it took to earn it.
Two writes leave the block entity, and neither leaves the server this tick.
The first is the fire: lit-ness is a block state, so the ticker calls
Level.setBlock on its own position with AbstractFurnaceBlock.LIT flipped.
ServerChunkCache.blockChanged marks the holder dirty — and the drain that
turns dirty holders into packets, ServerChunkCache.broadcastChangedChunks,
lives in the chunk-source phase, which ran before entities and long before
block entities. The second is BlockEntity.setChanged, which the tick calls
only when it lit, finished or went out, as it did here: it marks the chunk
unsaved and pokes comparators through
Level.updateNeighbourForOutputSignal — the unconditional half of that walk,
and the reason a comparator notices a hopper filling a chest (one int, and the
fan-out that exists to deliver
it)
— and that is all it does. It sends nothing.
So a viewer sees both a tick late, by two different routes. Next tick’s drain
sends the ClientboundBlockUpdatePacket and then — for every broadcast
position whose state has a block entity, including each position inside a
ClientboundSectionBlocksUpdatePacket — calls BlockEntity.getUpdatePacket,
one of the game’s two call sites (the other resends the blocks under a player
the server thinks is standing on air), and gets null from the furnace. Next tick’s
entity phase runs ServerPlayer.tick, which runs
AbstractContainerMenu.broadcastChanges, which compares the menu’s four data
slots against the values last sent and emits a
ClientboundContainerSetDataPacket per difference. Those four ints are the
furnace’s entire GUI: the flame is data 0 over data 1 and the arrow data 2
over data 3, read by AbstractFurnaceMenu.getLitProgress and
AbstractFurnaceMenu.getBurnProgress. The tick that lights the fire moves
three of them, because data 1 is the fuel’s total burn duration; after that
only 0 and 2 change, so a steady smelt is two packets a tick per open screen
and none at all with no viewer.
How a menu is opened, synchronised and closed is
containers and menus.
On the fire’s two-hundredth tick, AbstractFurnaceBlockEntity.burn moves the ingot into the result
slot and AbstractFurnaceBlockEntity.setRecipeUsed adds one to a counter map
— not to a recipe object, which is why
AbstractFurnaceBlockEntity.getRecipeUsed returns null. The experience is
paid out on collection: FurnaceResultSlot.checkTakeAchievements, reached
from a take or a shift-click, calls
AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience, which pops the
orbs at the player and unlocks the recipes.
Loaded is not enough to tick
Three things about that list are worth separating: the gates over it, the way it maintains itself, and the fact that what a ticker does between calls is nobody’s business but its own.
The two gates, and the distance that is not view distance
Level.tickBlockEntities walks one flat list, Level.blockEntityTickers,
under two gates the tickers themselves never see.
TickRateManager.runsNormally is the first, so /tick freeze stops every
block entity in the game. Level.shouldTickBlocksAt is the second, and it is
where the interesting asymmetry lives: on Level it is always true, on
ServerLevel it is DistanceManager.inBlockTickingRange — the
simulation chunk tracker, not the loading one (tickets and
loading). A chunk your view distance
keeps loaded and your simulation distance does not reach holds furnaces that
do not smelt, and nothing about the block entity records this: it is simply
never called. Below the gates,
LevelChunk.BoundTickingBlockEntity adds its own — not removed, adopted by a
level, inside the world border, and then, on a ServerLevel only, the chunk at
FullChunkStatus.BLOCK_TICKING with its entities loaded — before re-reading
the live state and ticking only while BlockEntityType.isValid still holds,
logging once and skipping while it does not.
The list that prunes itself
The list is never searched. Removal rebinds the chunk’s
LevelChunk.RebindableTickingBlockEntityWrapper to LevelChunk.NULL_TICKER,
whose TickingBlockEntity.isRemoved is permanently true, and the next pass of
Level.tickBlockEntities drops it on the way past — no search, on a list that
is walked every tick anyway. Additions made during the walk go to
Level.pendingBlockEntityTickers and are folded in at the top of the next
pass, so a block entity created by another block entity’s tick starts ticking
one tick later. The client runs the same method from Minecraft.tick, after
its entity pass and before ClientLevel.tick, and only while unpaused.
Three cadences the gates know nothing about
A ticker’s cadence is its own business, and the hopper is the first worth
knowing: HopperBlockEntity moves one item and then sets a cooldown of eight of
its own ticks, the number HopperBlockEntity.MOVE_ITEM_SPEED names, so a hopper
chain runs at two and a half items a second at twenty ticks a second. The two
spawners are the other two cadences worth knowing, and they are the block
entities that keep their whole personality outside themselves.
SpawnerBlockEntity holds an anonymous BaseSpawner and hands it the tick;
the delay, the spawn count, the required player range and the rolled
SpawnData all live on the BaseSpawner, which is not a BlockEntity at all.
Both sides tick it, and they run different methods: BaseSpawner.serverTick
does the spawning, and BaseSpawner.clientTick runs its own copy of the
countdown to spin the cube and drop smoke and flame — a local animation, never
a packet, and both are gated on BaseSpawner.isNearPlayer, so a spawner with
nobody in range neither spins nor fires. TrialSpawnerBlockEntity is the same
arrangement with a state machine on top: its TrialSpawner holds a
TrialSpawnerStateData, the TrialSpawnerState it moves through is a property
of the block state, and the block entity holds the TrialSpawner (entity
lifecycle owns what either
one spawns and under which reason). Every other block entity in
the sub-package is one of the shapes on this page — four hooks, a ticker handed
out per level, the four save shells, and the create-keep-replace-remove
lifecycle — with different fields in the middle.
Questions players ask
Why doesn’t the client know what is in a chest until I open it?
ChestBlockEntity overrides neither sync hook, so a chunk send carries its
type and position with no tag at all (an empty update tag is sent as none)
and the client builds its chest from the block state the packet’s sections
already decoded, with an empty container inside. What ticks on the client is
animation only: ChestBlock.getTicker hands out
ChestBlockEntity.lidAnimateTick on the client and null on the server, the
exact mirror of the furnace.
Why does a shulker box keep its contents when every other container drops
them? Because dropping is the base class’s behaviour, not the block’s:
BlockEntity.preRemoveSideEffects drops the contents of anything that
implements Container. Eight classes override that hook, and
ShulkerBoxBlockEntity is the one that overrides it to do nothing whatever.
The furnace overrides it too and calls the base version first, so it drops
like everything else and then pops the experience owed for uncollected smelts
at the block — awarding the recipes to nobody.
Why does the arrow only move when the screen is open? Because the arrow
is not a property of the furnace. It is data slot 2 of an
AbstractContainerMenu that exists only while a player has that screen open,
reconciled once per tick by the player who owns it. Close the screen and the
menu is gone, and the furnace goes on smelting with no packets at all.
Where to look
Start with the two hooks that decide everything else:
BlockEntity.getUpdatePacket and BlockEntity.setChanged, neither of which
sends anything for a furnace. BlockEntity.loadStatic is the one place a class
is chosen from a tag, and BlockEntity.preRemoveSideEffects is the last word
before one is unregistered. The lifecycle is
LevelChunk.setBlockState with LevelChunk.updateBlockEntityTicker and
LevelChunk.NULL_TICKER, and the ticking is Level.tickBlockEntities over
Level.shouldTickBlocksAt. Then AbstractFurnaceBlockEntity.serverTick for
the smelt itself and ChunkHolder.broadcastBlockEntity for the call site that asks a block entity for a packet whenever its position is broadcast. EntityBlock.getTicker and
BaseEntityBlock.createTickerHelper are not named above and are the door into
how any block hands out a ticker at all.
How a block entity is drawn — and why a chest’s block model is empty — is block-entity rendering, in Part XI.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.