MultiLoader 1.21+ · Part 23

Menus & Screens (MultiLoader 1.21+)

INTERMEDIATE MULTILOADER 1.21-1.21.1 30 min read · Oct 12, 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+)

Every container GUI in Minecraft, from furnaces to crafting tables, is split into two distinct parts: a menu class that runs on the server and manages the logical slot layout and item transfer rules, and a screen class that runs on the client and handles rendering. This tutorial extends the ExampleBlockEntity from the Block Entities tutorial with a nine-slot item inventory, then wires up a full menu and screen so players can open and interact with it in-game.

NOTE
Complete the Block Entities tutorial before continuing. This tutorial replaces the existing ExampleBlockEntity class entirely and adds an override to ExampleBlockEntityBlock.

Updating the Block Entity

The existing block entity extends BlockEntity directly. To store items and serve as a container, the base class must be changed to BaseContainerBlockEntity. This abstract class already implements Container, Nameable, and MenuProvider. It handles all the slot bookkeeping, the stillValid distance check, and delegates item NBT serialisation to ContainerHelper whenever you call super.saveAdditional(). You only need to supply the backing list and describe the menu to create.

Replace the entire ExampleBlockEntity class with the following:

java
public class ExampleBlockEntity extends BaseContainerBlockEntity {
private NonNullList<ItemStack> items = NonNullList.withSize(9, ItemStack.EMPTY);
private int secondsAlive = 0;
public ExampleBlockEntity(BlockPos pos, BlockState state) {
super(BlockEntityRegistry.EXAMPLE.get(), pos, state);
}
// ── Container ──────────────────────────────────────────────────
@Override
public int getContainerSize() {
return 9;
}
@Override
protected NonNullList<ItemStack> getItems() {
return items;
}
@Override
protected void setItems(NonNullList<ItemStack> items) {
this.items = items;
}
// ── MenuProvider ───────────────────────────────────────────────
@Override
protected Component getDefaultName() {
return Component.translatable("container.examplemod.example");
}
@Override
protected AbstractContainerMenu createMenu(int syncId, Inventory inventory) {
return new ExampleMenu(syncId, inventory, this);
}
// ── Ticking ────────────────────────────────────────────────────
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);
}
}
// ── NBT ────────────────────────────────────────────────────────
@Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries); // ContainerHelper saves the item list
tag.putInt("SecondsAlive", secondsAlive);
}
@Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries); // ContainerHelper restores the item list
secondsAlive = tag.getInt("SecondsAlive");
}
// ── Client sync ────────────────────────────────────────────────
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override
@Nullable
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
}

The critical change is the base class. BaseContainerBlockEntity routes all slot access through getItems(), enforces the stack size limit from getMaxStackSize(), and calls setChanged() automatically when a slot is modified. The stillValid implementation it provides checks that the player is within eight blocks of the block entity, which is the standard Minecraft rule for all containers. You do not need to implement this check yourself.

TIP
BaseContainerBlockEntity also supports loot tables. If a loot table key is stored on the block entity (for example, in a naturally-generated structure chest), the container saves that key to NBT rather than the item list, and generates the items on the first time a player opens it. For our simple block this feature is unused, but it is available at no extra cost.

The Menu Registry

Create a MenuRegistry class in your common registry package. A MenuType maps a registry name to a factory that reconstructs the menu on the client side. The factory takes only a sync ID and the player inventory because the client does not have a reference to the server-side block entity; vanilla populates the slots automatically via the slot-synchronisation packet sent immediately after the menu is opened.

java
public class MenuRegistry {
public static final RegistrationProvider<MenuType<?>> MENU_TYPES =
RegistrationProvider.get(Registries.MENU, Constants.MOD_ID);
public static final RegistryObject<MenuType<?>, MenuType<ExampleMenu>> EXAMPLE_MENU =
MENU_TYPES.register("example_menu",
() -> new MenuType<>(ExampleMenu::new, FeatureFlags.DEFAULT_FLAGS));
public static void init() {}
}

The ExampleMenu::new method reference matches the MenuSupplier<T> functional interface (int syncId, Inventory playerInventory) -> T. This refers to the two-argument client-side constructor you will write in the next section. The FeatureFlags.DEFAULT_FLAGS argument marks the menu as belonging to the default feature set; use FeatureFlags.VANILLA_SET if you ever need to flag it as an experimental feature instead.

In the access transformer and access widener we created back in the Block Entities tutorial, add the following entries to the access transformer:

text
public net.minecraft.world.inventory.MenuType$MenuSupplier
public net.minecraft.world.inventory.MenuType <init>(Lnet/minecraft/world/inventory/MenuType$MenuSupplier;Lnet/minecraft/world/flag/FeatureFlagSet;)V # <init>

And in the access widener:

text
accessible class net/minecraft/world/level/block/entity/BlockEntityType$BlockEntitySupplier
accessible method net/minecraft/world/inventory/MenuType <init> (Lnet/minecraft/world/inventory/MenuType$MenuSupplier;Lnet/minecraft/world/flag/FeatureFlagSet;)V
NOTE
After adding these entries, click Refresh Gradle (the elephant icon in IntelliJ, or run ./gradlew --refresh-dependencies) so the widening takes effect before your next build.

Next, make sure you call MenuRegistry.init() from CommonClass.init() after the block entity registry:

java
public class CommonClass {
public static void init() {
ArmourMaterialRegistry.init();
ParticleRegistry.init();
SoundRegistry.init();
ItemRegistry.init();
BlockRegistry.init();
BlockEntityRegistry.init();
MenuRegistry.init(); // This is our new entry!
CreativeTabRegistry.init();
EntityRegistry.init();
PacketRegistration.init();
}
}

The Menu Class

Create ExampleMenu in a new menu package inside your common project. The menu has two constructors: a server-side constructor that receives the real Container from the block entity, and a client-side constructor called by the MenuType factory that creates a temporary SimpleContainer which vanilla fills via the sync packet:

java
public class ExampleMenu extends AbstractContainerMenu {
private final Container container;
// Server-side — called by ExampleBlockEntity.createMenu()
public ExampleMenu(int syncId, Inventory playerInventory, Container container) {
super(MenuRegistry.EXAMPLE_MENU.get(), syncId);
this.container = container;
checkContainerSize(container, 9);
container.startOpen(playerInventory.player);
addContainerSlots();
addPlayerInventory(playerInventory);
}
// Client-side — called by the MenuType factory (no block entity available)
public ExampleMenu(int syncId, Inventory playerInventory) {
this(syncId, playerInventory, new SimpleContainer(9));
}
private void addContainerSlots() {
// 3x3 grid centred in a 176-wide GUI
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 3; col++) {
addSlot(new Slot(container, col + row * 3, 62 + col * 18, 17 + row * 18));
}
}
}
private void addPlayerInventory(Inventory playerInventory) {
// Main inventory — player slots 9-35, three rows
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 9; col++) {
addSlot(new Slot(playerInventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
}
}
// Hotbar — player slots 0-8
for (int col = 0; col < 9; col++) {
addSlot(new Slot(playerInventory, col, 8 + col * 18, 142));
}
}
@Override
public boolean stillValid(Player player) {
return this.container.stillValid(player);
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
ItemStack returnStack = ItemStack.EMPTY;
Slot slot = this.slots.get(index);
if (slot.hasItem()) {
ItemStack stack = slot.getItem();
returnStack = stack.copy();
if (index < 9) {
// Container slot to player inventory, fill from the end
if (!moveItemStackTo(stack, 9, 45, true)) return ItemStack.EMPTY;
} else {
// Player inventory or hotbar to container
if (!moveItemStackTo(stack, 0, 9, false)) return ItemStack.EMPTY;
}
if (stack.isEmpty()) {
slot.set(ItemStack.EMPTY);
} else {
slot.setChanged();
}
}
return returnStack;
}
@Override
public void removed(Player player) {
super.removed(player);
this.container.stopOpen(player);
}
}

checkContainerSize throws a descriptive exception at startup if the container reports fewer slots than expected, which catches registration mismatches early. startOpen and stopOpen are used by vanilla to play lid animations on chests; they are no-ops on SimpleContainer but are worth calling so any future Container implementation works correctly.

Slot Layout

Slots are positioned in pixels relative to the top-left corner of the background texture. Vanilla slot rendering draws the 16x16 item icon one pixel inset from the position you provide, so the coordinates mark the top-left corner of the 18x18 slot area (including the 1px border). Adjacent slots are spaced 18px apart.

The slot indices in this.slots follow the order in which you call addSlot:

  • 0-8: the nine container slots added by addContainerSlots().
  • 9-35: the 27 main player inventory slots (rows 1-3, indices 9-35 of the player's own inventory).
  • 36-44: the nine hotbar slots (indices 0-8 of the player's own inventory).

quickMoveStack uses these ranges to route shift-click actions. A click on index 0-8 tries to push the stack into the player inventory (slots 9-44). A click on 9-44 tries to push into the container (slots 0-8). The true argument to moveItemStackTo for the container direction fills slots from the end of the range backwards, matching vanilla's fill order for most containers.

TIP
The player inventory starts at y=84 and the hotbar at y=142 for a standard 176x166 GUI. If you add extra rows of container slots above, shift both y values down by 18 for each extra row you add, and increase imageHeight in the screen class by the same amount.

The Screen Class

The screen handles all client-side rendering. Because it extends the vanilla AbstractContainerScreen, which is a pure Minecraft class, it can live in a client.screen package inside your common project rather than in a loader-specific source set. Create ExampleScreen there:

java
public class ExampleScreen extends AbstractContainerScreen<ExampleMenu> {
private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(
Constants.MOD_ID, "textures/gui/example_menu.png");
public ExampleScreen(ExampleMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
this.imageWidth = 176;
this.imageHeight = 166;
}
@Override
protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.blit(TEXTURE, leftPos, topPos, 0, 0, imageWidth, imageHeight);
}
@Override
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, title, titleLabelX, titleLabelY, 0x404040, false);
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
}

leftPos and topPos are computed by AbstractContainerScreen to centre the GUI on screen. renderBg blits the full background texture starting at those coordinates. renderLabels is called after renderBg with the matrix already translated to (leftPos, topPos), so label positions are relative to the texture origin, not the screen origin.

titleLabelX and titleLabelY default to 8, 6. inventoryLabelX and inventoryLabelY default to 8, imageHeight - 94, which places the "Inventory" label just above the player inventory rows. Override these fields in your constructor if you adjust imageHeight or move the player inventory.

The GUI Texture

Create the texture at src/main/resources/assets/examplemod/textures/gui/example_menu.png inside your common project. The PNG must be exactly 256x256 pixels (the largest power-of-two that contains the 176x166 GUI area). Only the top-left 176x166 region is sampled by the blit call; the rest can be left transparent.

The standard layout for a 176x166 container background:

  • A solid stone-grey panel filling the full 176x166 area.
  • Nine container slot cutouts at x = 62, 80, 98 and y = 17, 35, 53 (18x18 each, 1px dark border, slightly lighter interior).
  • 27 player inventory slots at x = 8 to 152, nine per row at y = 84, 102, 120.
  • Nine hotbar slots at x = 8 to 152, y = 142, separated by a 4px gap from the inventory rows above.

You can use the vanilla file assets/minecraft/textures/gui/container/dispenser.png (extract it from the Minecraft client jar, which can be opened as a zip archive) as a colour and style reference which also happens to match our 3x3 layout. Save as a 32-bit PNG with alpha to preserve transparency in the unused region.

NOTE
The vanilla GUI uses a specific grey palette: the outer panel is approximately #C6C6C6, slot borders are #8B8B8B (dark) and #FFFFFF (light highlight), and the slot interior is #8B8B8B. Matching these values keeps your GUI visually consistent with vanilla containers.

Opening the Menu

Add a useWithoutItem override to ExampleBlockEntityBlock. This method is called when a player right-clicks the block without holding an item. The block entity already implements MenuProvider via BaseContainerBlockEntity, so you only need to retrieve it and call openMenu:

java
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level,
BlockPos pos, Player player,
BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer) {
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof MenuProvider menuProvider) {
serverPlayer.openMenu(menuProvider);
}
}
return InteractionResult.SUCCESS;
}

Returning InteractionResult.SUCCESS on both sides prevents any held item from being used. The server calls menuProvider.createMenu() internally, assigns a synchronisation ID, sends an OpenScreenPacket to the client, and begins slot-syncing. The client receives the packet, uses the MenuType factory to construct a local ExampleMenu with a temporary SimpleContainer, then opens the registered screen class.

Loader Screen Registration

The association between a MenuType and its screen class must be declared at startup on each loader. This is the one place where loader-specific code is required.

On NeoForge, add the menu screen registry to our ClientEvents class we created in a previous tutorial:

java
@EventBusSubscriber(bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public class ClientEvents {
// Other methods
@SubscribeEvent
public static void registerMenuScreens(RegisterMenuScreensEvent event) {
event.register(MenuRegistry.EXAMPLE_MENU.get(), ExampleScreen::new);
}
}

On Fabric, register in your client mod initialiser we created in a previous tutorial:

java
public class ExampleModClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
// Other previous entries
MenuScreens.register(MenuRegistry.EXAMPLE_MENU.get(), ExampleScreen::new);
}
}

MenuScreens.register(...) expect a three-argument constructor reference matching (MenuType menu, Inventory inv, Component title) -> AbstractContainerScreen, which matches ExampleScreen exactly.

Dropping Items on Break

By default, breaking the block destroys all items stored in the container. Override onRemove in ExampleBlockEntityBlock to scatter the contents into the world when the block is replaced by a different block:

java
@Override
public void onRemove(BlockState state, Level level, BlockPos pos,
BlockState newState, boolean movedByPiston) {
if (!state.is(newState.getBlock())) {
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof Container container) {
Containers.dropContents(level, pos, container);
level.updateNeighbourForOutputSignal(pos, this);
}
}
super.onRemove(state, level, pos, newState, movedByPiston);
}

Containers.dropContents iterates every slot and spawns an item entity at the block position for each non-empty stack. The state.is(newState.getBlock()) check ensures items are only dropped when the block type actually changes; if the same block is replaced (for example, by a block state update), the inventory is preserved.

Testing

Add a lang entry for the container title in the ExampleLangProvider, then re-run the datagen:

json
add("container.examplemod.example", "Example Container");

Launch a creative-mode world and place the block. Right-click without an item in hand. The GUI should open showing the background texture, nine container slots in a 3x3 grid, the "Example Container" title, and the player inventory below. Place some items in the container slots, close the screen, and reopen to confirm they persist. Log out and back in to confirm they survive a world save.

If the screen opens but shows a blank or black background, verify the texture path is exactly assets/examplemod/textures/gui/example_menu.png and the file is 256x256. If the screen does not open at all, check the client log for an unregistered screen for menu type error, which means the screen registration call in the client setup did not run during startup. Break the block and confirm the stored items drop onto the ground.

You can find the source for this tutorial here:

View Source on GitHub
NEXT IN SERIES

Key Bindings (MultiLoader 1.21+)

Define KeyMapping instances in common client code, register them via RegisterKeyMappingsEvent on NeoForge and KeyBindingHelper on Fabric, poll consumeClick() each client tick, and optionally fire a C2S packet to trigger server-side actions.

Continue →