MultiLoader 1.21+ · Part 12

Block Entities (MultiLoader 1.21+)

INTERMEDIATE MULTILOADER 1.21-1.21.1 25 min read · Jul 27, 2026
MultiLoader 1.21+ · 28 parts
1 Getting Started with MultiLoader 1.21+ 2 Setting Up RegistrationUtils 3 Creating Items (MultiLoader 1.21+) 4 Creating Blocks (MultiLoader 1.21+) 5 Data Generation: Block & Item Models (MultiLoader 1.21+) 6 Data Generation: Block Loot Tables (MultiLoader 1.21+) 7 Data Generation: Crafting Recipes (MultiLoader 1.21+) 8 Data Generation: Block & Item Tags (MultiLoader 1.21+) 9 Custom Food Items (MultiLoader 1.21+) 10 Custom Tools (MultiLoader 1.21+) 11 Custom Armour (MultiLoader 1.21+) 12 Block Entities (MultiLoader 1.21+) 13 Config Files (MultiLoader 1.21+) 14 Custom Sounds (MultiLoader 1.21+) 15 Events and Listeners (MultiLoader 1.21+) 16 Networking and Custom Packets (MultiLoader 1.21+) 17 Data Generation: Advancements (MultiLoader 1.21+) 18 Data Generation: Language Files (MultiLoader 1.21+) 19 Custom Entities (MultiLoader 1.21+) 20 Ore Generation (MultiLoader 1.21+) 21 Introduction to Mixins (MultiLoader 1.21+) 22 Custom Particles (MultiLoader 1.21+) 23 Menus & Screens (MultiLoader 1.21+) 24 Key Bindings (MultiLoader 1.21+) 25 Custom Potion Effects (MultiLoader 1.21+) 26 Custom Enchantments (MultiLoader 1.21+) 27 Custom Commands (MultiLoader 1.21+) 28 Custom Biomes (MultiLoader 1.21+)

A block entity (formerly called a tile 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 Wideners & Transformers

BlockEntityType.BlockEntitySupplier, the functional interface that BlockEntityType.Builder.of() accepts, is package-private in Minecraft's source. That means any reference to it from mod code (including lambdas and method references) triggers an access error at compile time. Each loader has its own mechanism for widening visibility before the build runs: NeoForge uses an access transformer and Fabric uses an access widener. Both files live in the common project so the same declaration covers both loaders.

NOTE
An amazing reference to find mappings and copy Access Transformer and Access Widener entries directly is called Linkie.

Create src/main/resources/META-INF/accesstransformer.cfg:

text
# NeoForge access transformer
public net.minecraft.world.level.block.entity.BlockEntityType$BlockEntitySupplier

Create src/main/resources/examplemod.accesswidener:

text
accessWidener v2 named
accessible class net/minecraft/world/level/block/entity/BlockEntityType$BlockEntitySupplier
NOTE
After creating both files, click Refresh Gradle (the elephant icon in IntelliJ, or run ./gradlew --refresh-dependencies) so the widening takes effect before your next build. Your Fabric subproject's fabric.mod.json also needs "accessWidener": "examplemod.accesswidener". If your template already includes that line, no change is needed there.

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 valid block listed in Builder.of is one that 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). In 1.21.1, 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 (from Block) 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 also required. BaseEntityBlock marks it @Deprecated in 1.21.1 but still returns RenderShape.INVISIBLE by default, which hides the block model.

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();
CreativeTabRegistry.init();
}
}

Datagen and Testing

Add the new block to your existing datagen providers:

  • BlockStateProvider: simpleBlockWithItem(BlockRegistry.EXAMPLE_BE_BLOCK.get(), cubeAll(...))
  • BlockLootTableProvider: dropSelf(BlockRegistry.EXAMPLE_BE_BLOCK.get())
  • BlockTagsProvider: add to MINEABLE_WITH_PICKAXE
  • Language file: add a block.examplemod.example_be_block entry

Place the block in a creative-mode world and switch to survival. Right-click the block to check that nothing crashes. Use /data get block ~ ~ ~ SecondsAlive at the block's position to confirm the counter is incrementing and persisting correctly. Break and replace the block to verify the counter resets to zero, confirming that the block entity is being 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 future tutorial on custom menus.

You can find the source for this tutorial here:

View Source on GitHub
NEXT IN SERIES

Config Files (MultiLoader 1.21+)

Define a common config interface, implement it with NeoForge ModConfigSpec and a Fabric Gson JSON file, and read config values from shared code without loader-specific imports.

Continue →