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

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

classwhat it decidesthread
BlockEntitythe 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 nothingwhichever thread owns the level
BlockEntityTypewhich blocks the entity is legal on, and what constructs itimmutable once the registry is built
EntityBlockwhether a block has an entity at all, which one, and which ticker per level—
LevelChunkthe position-to-entity map, the ticker wrapper per position, and create / keep / replace / remove on every writethe chunk’s owning thread
Levelthe flat list of tickers and the two gates over itServer thread, or the Render thread
AbstractFurnaceBlockEntitythree slots, four ints, a cached recipe check, and when the block’s lit state has to changeServer thread only — its ticker is null on the client
ChunkHolderwhich positions changed since the last drain, and the call to BlockEntity.getUpdatePacket for each broadcast position that has a block entityServer thread, chunk-source phase
FurnaceMenuwhat an open screen is allowed to see of all that: three slots and four intsServer 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 runswhat it writeswho calls it
BlockEntity.saveAdditionalthe subclass’s own fields, and nothing elsenobody directly
BlockEntity.saveCustomOnlythat alonethirteen of the tag overrides, the pick-block path, /clone and a few others
BlockEntity.saveWithoutMetadatathat plus componentsthe two below, plus BlockInput, the falling block and a few others
BlockEntity.saveWithIdthat plus ida structure template, which records a relative position of its own, and the adventure-mode block check’s cache
BlockEntity.saveWithFullMetadatathat plus id, x, y and zLevelChunk.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.