MultiLoader 26.1+ · Part 17

Block Entities (MultiLoader 26.1+)

INTERMEDIATE MULTILOADER 26.1-26.1.2 25 min read · Jun 12, 2026
MultiLoader 26.1+ · 17 parts
1 Getting Started with MultiLoader 26.1+ 2 Setting Up RegistrationUtils (MultiLoader 26.1+) 3 Creating Items (MultiLoader 26.1+) 4 Creating Blocks (MultiLoader 26.1+) 5 Data Generation: Block & Item Models (MultiLoader 26.1+) 6 Data Generation: Block Loot Tables (MultiLoader 26.1+) 7 Data Generation: Crafting Recipes (MultiLoader 26.1+) 8 Data Generation: Block & Item Tags (MultiLoader 26.1+) 9 Data Generation: Language Files (MultiLoader 26.1+) 10 Data Generation: Advancements (MultiLoader 26.1+) 11 Data Generation: Sound Definitions (MultiLoader 26.1+) 12 Data Generation: Particle Descriptions (MultiLoader 26.1+) 13 Data Generation: Enchantments (MultiLoader 26.1+) 14 Custom Food Items (MultiLoader 26.1+) 15 Custom Tools (MultiLoader 26.1+) 16 Custom Armour (MultiLoader 26.1+) 17 Block Entities (MultiLoader 26.1+)

A block entity attaches persistent data and optional ticking logic to a block. Furnaces, chests, and command blocks all use them. This tutorial creates a simple ticking block entity that counts elapsed seconds and persists that value across world saves and server restarts.

NOTE
Complete the Creating Blocks tutorial before continuing. We register a new block that owns the block entity.

Access Transformer & Class Tweaker

BlockEntityType.BlockEntitySupplier, the functional interface that BlockEntityType.Builder.of() accepts, is package-private in Minecraft's source. Any reference to it from mod code triggers an access error at compile time. The same AT and class tweaker pattern introduced in the Creating Items tutorial is used here: add one new entry to each file.

NOTE
Linkie is a useful reference for finding the exact class names you need to widen. Select your Minecraft version from the dropdown. Since 26.1 uses official Mojang mappings, the names you see there match exactly what you write in the AT and class tweaker files.

Add to common/src/main/resources/META-INF/accesstransformer.cfg:

text
public net.minecraft.world.level.block.entity.BlockEntityType$BlockEntitySupplier

Add to common/src/main/resources/examplemod.classtweaker:

text
accessible class net/minecraft/world/level/block/entity/BlockEntityType$BlockEntitySupplier

Click Refresh Gradle after saving both files so the widening takes effect before your next build.

Block Entity Registry

Create a new class called BlockEntityRegistry in your common registry package:

java
public class BlockEntityRegistry {
public static final RegistrationProvider<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
RegistrationProvider.get(Registries.BLOCK_ENTITY_TYPE, Constants.MOD_ID);
public static final RegistryObject<BlockEntityType<?>, BlockEntityType<ExampleBlockEntity>> EXAMPLE =
BLOCK_ENTITY_TYPES.register("example", () ->
BlockEntityType.Builder
.of(ExampleBlockEntity::new, BlockRegistry.EXAMPLE_BE_BLOCK.get())
.build(null));
public static void init() {}
}

The build(null) argument is the DataFixer type, which is null for all modded block entities that do not use the vanilla data-fixing system. Each block listed in Builder.of is one this block entity type can be attached to.

The Block Entity Class

Create ExampleBlockEntity in a new blockentity package in your common project:

java
public class ExampleBlockEntity extends BlockEntity {
private int secondsAlive = 0;
public ExampleBlockEntity(BlockPos pos, BlockState state) {
super(BlockEntityRegistry.EXAMPLE.get(), pos, state);
}
public int getSecondsAlive() {
return secondsAlive;
}
public static void tick(Level level, BlockPos pos, BlockState state, ExampleBlockEntity entity) {
if (level.isClientSide()) return;
if (level.getGameTime() % 20 == 0) {
entity.secondsAlive++;
entity.setChanged();
level.sendBlockUpdated(pos, state, state, Block.UPDATE_CLIENTS);
}
}
}

The static tick method matches the signature required by BlockEntityTicker. Calling setChanged() marks the chunk dirty so the data is included in the next world save. Calling level.sendBlockUpdated queues a sync packet to nearby clients.

The Block Class

Blocks that own a block entity must extend BaseEntityBlock (which implements the EntityBlock interface). BaseEntityBlock declares codec() as abstract, so every subclass must provide a MapCodec. You also need to override getRenderShape to restore solid model rendering, since BaseEntityBlock returns RenderShape.INVISIBLE by default:

java
public class ExampleBlockEntityBlock extends BaseEntityBlock {
public static final MapCodec<ExampleBlockEntityBlock> CODEC =
simpleCodec(ExampleBlockEntityBlock::new);
public ExampleBlockEntityBlock(Properties properties) {
super(properties);
}
@Override
protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new ExampleBlockEntity(pos, state);
}
@Override
@Nullable
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(
Level level, BlockState state, BlockEntityType<T> type) {
return createTickerHelper(type, BlockEntityRegistry.EXAMPLE.get(), ExampleBlockEntity::tick);
}
}
TIP
The inherited simpleCodec helper builds the required MapCodec from any constructor that takes only a Properties argument. If your block constructor takes additional parameters you will need to write the codec manually using RecordCodecBuilder. The getRenderShape override is required: without it the block model is invisible.

Saving and Loading Data

Override saveAdditional and loadAdditional in ExampleBlockEntity to persist custom fields to NBT:

java
@Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
tag.putInt("SecondsAlive", secondsAlive);
}
@Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
secondsAlive = tag.getInt("SecondsAlive");
}

Always call super first so the base class can save and load its own fields such as the block entity type and position. Use unique NBT key names to avoid collisions if you extend this class later.

Syncing to the Client

Add getUpdateTag and getUpdatePacket so the block entity state reaches connected clients when a chunk loads or when sendBlockUpdated is called:

java
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override
@Nullable
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}

saveWithoutMetadata serialises all of the block entity's custom data without including the type ID or position, keeping the packet small. The client calls loadAdditional when it receives the packet, so no extra handling is needed.

Wiring Up

Register the block entity block in BlockRegistry:

java
public static final RegistryObject<Block, ExampleBlockEntityBlock> EXAMPLE_BE_BLOCK =
registerBlock("example_be_block",
() -> new ExampleBlockEntityBlock(BlockBehaviour.Properties.of().strength(1.5f)));

Then call BlockEntityRegistry.init() from CommonClass.init() after the block registry so all blocks exist before the block entity type is built:

java
public class CommonClass {
public static void init() {
ArmourMaterialRegistry.init();
ItemRegistry.init();
BlockRegistry.init();
BlockEntityRegistry.init();
SoundRegistry.init();
ParticleRegistry.init();
CreativeTabRegistry.init();
}
}

Datagen and Testing

Add the new block to your existing datagen providers:

  • ExampleModelProvider: call blockModels.createTrivialCube(BlockRegistry.EXAMPLE_BE_BLOCK.get()) and blockModels.registerSimpleItemModel(BlockRegistry.EXAMPLE_BE_BLOCK.get(), ModelLocationUtils.getModelLocation(BlockRegistry.EXAMPLE_BE_BLOCK.get()))
  • ExampleBlockLootTableProvider: dropSelf(BlockRegistry.EXAMPLE_BE_BLOCK.get())
  • ExampleBlockTagsProvider: add to MINEABLE_WITH_PICKAXE
  • ExampleLanguageProvider: add(BlockRegistry.EXAMPLE_BE_BLOCK.get(), "Example Block Entity Block")

Place the block in a creative-mode world and switch to survival. Use /data get block ~ ~ ~ SecondsAlive at the block's position to confirm the counter is incrementing and persisting correctly across chunk reloads. Break and replace the block to verify the counter resets to zero, confirming the block entity is recreated on placement.

TIP
To add a player-facing inventory to your block entity, implement Container or extend BaseContainerBlockEntity and pair it with a MenuType registration and a screen class. That pattern is covered in a later tutorial on custom menus.

You can find the source for this tutorial here:

View Source on GitHub