Last week I started working on the Butterfly Microscope, a block that would allow players to learn more about butterflies and craft butterfly books. This week I finished implementing it, and made a new release so people can play with new block already.
Clean Up
Part of the motivation for this new block is to fix the buggy crafting for the butterfly book. With this new block, there will be a new way to craft them that uses much more robust code. So, before I started implementing this block I first deleted all of the butterfly book recipes.

This amounted to around 170 JSON files being removed from the project! It’s definitely a good thing when you can do exactly the same as before using less code and less data. While this mod isn’t huge, keeping the size of a mod down also makes it easier to download, and means there is less data to load when a world is launched. This both speeds up the game and reduces the amount of memory needed to support the mod.
Slots
To create a new menu I need to start with the item slots. Slots contain items, and are used in inventories, crafting tables, smelters, and the like. Custom slots can limit what kinds of items can go into them. The fuel slot for a furnace is limited to wood, coal, lava, and other burnable items as an example.
In my case I want two special slots: one that takes only Books or Butterfly Books, and one that will only take Butterfly Scrolls. I do this by extending the Slot
class and overriding mayPlace()
:
/** * A slot that limits what can be placed in it to Books and Butterfly Books. */ public class ButterflyBookSlot extends Slot { /** * Construction. * @param container The container. * @param slotIndex The slot index. * @param xPos The screen coordinates for the slot. * @param yPos The screen coordinates for the slot. */ public ButterflyBookSlot(Container container, int slotIndex, int xPos, int yPos) { super(container, slotIndex, xPos, yPos); } /** * Tells the game if the item may be placed in the slot. * @param itemStack The item stack to test. * @return TRUE if the item can be placed in the slot. */ @Override public boolean mayPlace(ItemStack itemStack) { return itemStack.getItem() instanceof ButterflyBookItem || itemStack.getItem() instanceof BookItem; } }
As you can see in the above example, mayPlace()
will only return true
if the item is an instance of a Book
or a ButterflyBook
. I implement a similar class, ButterflyScrollSlot
that checks if the item is a ButterflyScroll
instead.
The third slot I need is the result slot. This is where a crafted item will be placed so that a player can remove it. This is the act of crafting the item, and every result slot behaves in a slightly different way. None of the vanilla result slots work the way I would like them to, so I created my own ButterflyBookResultSlot
instead:
/** * Handles crafting butterfly books with the butterfly microscope. */ public class ButterflyBookResultSlot extends Slot { private final CraftingContainer craftSlots; /** * Construction * @param craftingContainer The container for the crafting slots. * @param container The container for the result. * @param slotIndex The index of this result. * @param x The x-position of this slot. * @param y The y-position of this slot. */ public ButterflyBookResultSlot(CraftingContainer craftingContainer, Container container, int slotIndex, int x, int y) { super(container, slotIndex, x, y); this.craftSlots = craftingContainer; } /** * Disallow players putting items in result slots. * @param itemStack The stack to place. * @return Always FALSE. */ @Override public boolean mayPlace(@NotNull ItemStack itemStack) { return false; } /** * Remove ingredients when a new book is crafted. * @param player The player crafting the boot. * @param itemStack The item stack to take. */ @Override public void onTake(@NotNull Player player, @NotNull ItemStack itemStack) { for (int i = 0; i < craftSlots.getContainerSize(); ++i) { ItemStack craftItem = this.craftSlots.getItem(i); if (!craftItem.isEmpty()) { this.craftSlots.removeItem(i, 1); } } } }
This class does two important things. First, mayPlace()
will always return false
, meaning players cannot place items into the slot. Second, I implement a basic crafting method by overriding onTake()
. This is called when a player takes an item from the slot, and it will reduce the number of items in all crafting slots by 1.
This gives me slots that I can use in the menu, so the next step is to create that menu and connect everything together.
Crafting
To handle the crafting I created a new menu class, the ButterflyMicroscopeMenu
. This will implement the AbstractContainerMenu
since it is a menu that works with items in the player’s inventory. The first thing to do is to set up the slots in the constructor. The menu will need 2 crafting slots and 1 result slot for crafting, and it will also need all the slots for the player’s inventory.
/** * The menu for a butterfly microscope. Handles crafting of Butterfly Books. */ public class ButterflyMicroscopeMenu extends AbstractContainerMenu { // The item registry. private final ItemRegistry itemRegistry; // The crafting slot container. private final CraftingContainer craftSlots; // The result slot container. private final ResultContainer resultSlots; // The container the menu is interfacing with. private final ContainerLevelAccess containerLevelAccess; // The player. private final Player player; /** * Client constructor. * @param menuType The type of this menu. * @param containerId The ID of the container. * @param playerInventory The player's inventory. */ public ButterflyMicroscopeMenu(MenuType<?> menuType, int containerId, Inventory playerInventory) { this(null, menuType, containerId, playerInventory, ContainerLevelAccess.NULL); } /** * Server constructor. * @param menuType The type of this menu. * @param containerId The ID of the container. * @param playerInventory The player's inventory. * @param container The container for the feeder. */ public ButterflyMicroscopeMenu(ItemRegistry itemRegistry, MenuType<?> menuType, int containerId, Inventory playerInventory, ContainerLevelAccess container) { super(menuType, containerId); this.itemRegistry = itemRegistry; this.player = playerInventory.player; this.craftSlots = new TransientCraftingContainer(this, 2, 1); this.resultSlots = new ResultContainer(); this.containerLevelAccess = container; // TODO: Update positions. this.addSlot(new ButterflyBookResultSlot(this.craftSlots, this.resultSlots, 0, 204, 17)); this.addSlot(new ButterflyBookSlot(craftSlots, 0, 132, 17)); this.addSlot(new ButterflyScrollSlot(craftSlots, 1, 150, 17)); for(int i = 0; i < 3; ++i) { for(int j = 0; j < 9; ++j) { this.addSlot(new Slot(playerInventory, j + i * 9 + 9, 96 + j * 18, i * 18 + 47)); } } for(int i = 0; i < 9; ++i) { this.addSlot(new Slot(playerInventory, i, 96 + i * 18, 105)); } } }
The default constructor is only used when registering the menu. The other constructor will always be used when the menu is opened by the player. I hold onto a reference to the ItemRegistry
here so I can use it to create a Butterfly Book later.
The next method we need is an override of quickMoveStack()
. This method is called when a player shift-clicks on an item in the menu. It decides where the stack should move to, if anywhere. Since this block is basically a crafting table with two slots, I was able to just copy the code from CraftingMenu
in the vanilla code and modify a couple of index values to reflect that there were only 2 crafting slots instead of 9.
To prevent items being lost, any items in the crafting slots should be moved back to the player’s inventory when the menu closes. This is the default behaviour of most of Minecraft’s crafting blocks, and means that they don’t need to save their inventories when the game closes (in other words, we don’t need to use a Block Entity). I override the remove()
method to ensure any items get moved back before the menu is closed.
/** * Called when the block entity is removed. * @param player The player interacting with the menu. */ @Override public void removed(@NotNull Player player) { super.removed(player); this.containerLevelAccess.execute((level, blockPos) -> this.clearContainer(player, this.craftSlots)); }
I also need an isValid()
override in the class. This method just checks to make sure that the block is still a Butterfly Microscope and that the player is still in range of the block.
/** * Check the block entity is still valid. * @param player The player interacting with the menu. * @return TRUE if the block is still valid. */ @Override public boolean stillValid(@NotNull Player player) { return containerLevelAccess.evaluate((level, blockPos) -> level.getBlockState(blockPos).getBlock() instanceof ButterflyMicroscopeBlock && player.distanceToSqr( (double) blockPos.getX() + 0.5, (double) blockPos.getY() + 0.5, (double) blockPos.getZ() + 0.5) <= 64.0, true); }
In order to allow for crafting, I need to overload the slotsChanged()
method. This gets called every time the slots change in the inventory and can be used to update the state of the menu.
/** * Called when the slots are changed by the player. * @param container The current container.s */ @Override public void slotsChanged(@NotNull Container container) { this.containerLevelAccess.execute((level, blockPos) -> onCraftingGridSlotChanged(this, level, this.player, this.craftSlots, this.resultSlots)); }
In this case I call a separate function to handle the slot changes. Many blocks in Minecraft use the recipe book to guide players to the recipes they can use. However, for this block there is only really one recipe: a Book/Butterfly Book and a Butterfly Scroll. For this reason, and because it makes the code a lot simpler, I’ve opted to not use the recipe book for this block.
So in the implementation of onCraftingGridSlotChanged()
I test to see if both crafting slots are full, and if so I update the result slot with the new Butterfly Book.
/** * Update the resulting book if there is a valid recipe. * @param menu The menu. * @param level The current level. * @param player The player interacting with the menu. * @param craftingContainer The crafting slots. * @param resultContainer The result slots. */ private void onCraftingGridSlotChanged(AbstractContainerMenu menu, Level level, Player player, CraftingContainer craftingContainer, ResultContainer resultContainer) { // This is server-side, the state will automatically get synced to the client. if (!level.isClientSide) { ServerPlayer serverPlayer = (ServerPlayer)player; ItemStack result = ItemStack.EMPTY; // Check if there is an item in the book slot. ItemStack book = craftingContainer.getItem(0); if (!book.isEmpty()) { // Check if there is an item in the scroll slot. ItemStack scroll = craftingContainer.getItem(1); if (scroll != ItemStack.EMPTY) { if (scroll.getItem() instanceof ButterflyScrollItem scrollItem) { // Both slots are full so either create a new Butterfly // Book if we are crafting with a vanilla Book, or copy // the current Butterfly Book so we keep the pages in // the newly crafted book. if (book.is(itemRegistry.getButterflyBook().get())) { result = book.copy(); } else { result = new ItemStack(itemRegistry.getButterflyBook().get()); } // Try to add the new page. If the page is already in // the book, this returns false, so we can set the // result to empty again. if (!ButterflyBookItem.addPage(result, scrollItem.getButterflyIndex())) { result = ItemStack.EMPTY; } } } } // Set the result slot and send it to the client. resultContainer.setItem(0, result); menu.setRemoteSlot(0, result); serverPlayer.connection.send(new ClientboundContainerSetSlotPacket(menu.containerId, menu.incrementStateId(), 0, result)); } }
Now I have a menu that can be used to craft butterfly books. To finish this off I register the menu with the MenuTypeRegistry
in a similar way to how I registered the menu for the Butterfly Feeder. I also add a couple of methods to the ButterflyMicroscopeBlock
class that will open the menu when a player right-clicks on the block.
/** * Open the menu when the block is interacted with. * @param blockState The block's state. * @param level The current level. * @param blockPos The block's position. * @param player The player using the block. * @param interactionHand The hand interacting with the block. * @param blockHitResult The result of the collision detection. * @return The result of the interaction (usually consumed). */ @NotNull @Override @SuppressWarnings("deprecation") public InteractionResult use(@NotNull BlockState blockState, Level level, @NotNull BlockPos blockPos, @NotNull Player player, @NotNull InteractionHand interactionHand, @NotNull BlockHitResult blockHitResult) { if (level.isClientSide) { return InteractionResult.SUCCESS; } else { player.openMenu(blockState.getMenuProvider(level, blockPos)); return InteractionResult.CONSUME; } } /** * Provides the menu for this block. * @param blockState The current block state. * @param level The current level. * @param blockPos The position of the block. * @return A new menu provider. */ @Override @SuppressWarnings("deprecation") public MenuProvider getMenuProvider(@NotNull BlockState blockState, @NotNull Level level, @NotNull BlockPos blockPos) { return new SimpleMenuProvider((containerId, inventory, title) -> new ButterflyMicroscopeMenu( itemRegistry, menuTypeRegistry.getButterflyMicroscopeMenu().get(), containerId, inventory, ContainerLevelAccess.create(level, blockPos)), CONTAINER_TITLE); }
Now we have a fully functional menu, but there are no textures for the menu so it doesn’t actually look like anything.
Rendering
For the menu’s texture I kept it simple as always. Since I’m not using the Recipe Book, I wanted to indicate to players what they can put into the container without needing a tutorial of any kind. So I drew in the outlines of a book and a piece of paper in the slots themselves.
This should give players enough clues to figure out what they are supposed to put into the crafting slots. The arrow pointing to the result slot also gives players a hint that it is a result slot that they can’t place any items in.
To render this I create the ButterflyMicroscopeScreen
class that is only distributed with client builds, since servers don’t need to render the menu screens. One feature I want to include with this menu is the ability to get information on butterflies without having to create a butterfly book.
To do this, I moved the code that gets the formatted string from the ButterflyBookScreen
to the ButterflyData
. I can now use ButterflyData.getFormattedButterflyData()
to get the same data here as well and render it on top of an empty scroll texture.
/** * Render the background. * @param guiGraphics The graphics object. * @param unknown Unknown. * @param mouseX Mouse x-position. * @param mouseY Mouse y-position. */ @Override protected void renderBg(@NotNull GuiGraphics guiGraphics, float unknown, int mouseX, int mouseY) { // Render the microscope menu. int x = (this.width) / 2; int y = (this.height - this.imageHeight) / 2; guiGraphics.blit(ButterflyTextures.MICROSCOPE, x, y, 0, 0, this.imageWidth, this.imageHeight); // Get the formatted butterfly information, if any. int butterflyIndex = this.menu.getButterflyScrollIndex(); if (butterflyIndex >= 0) { ButterflyData data = ButterflyData.getEntry(butterflyIndex); if (data != null) { if (this.cachedButterflyIndex != butterflyIndex) { FormattedText formattedText = ButterflyData.getFormattedButterflyData(butterflyIndex); if (formattedText != null) { this.cachedPageComponents = this.font.split(formattedText, 114); } } } } else { this.cachedPageComponents = Collections.emptyList(); } this.cachedButterflyIndex = butterflyIndex; // Render the scroll background. x = ((this.width) / 2) - 176; y = (this.height - 192) / 2; guiGraphics.blit(ButterflyTextures.SCROLL, x, y, 0, 0, 192, 192); // Render the butterfly text, if any. int cachedPageSize = Math.min(128 / 9, this.cachedPageComponents.size()); for (int line = 0; line < cachedPageSize; ++line) { FormattedCharSequence formattedCharSequence = this.cachedPageComponents.get(line); guiGraphics.drawString(this.font, formattedCharSequence, x + 36, 50 + line * 9, 0, false); } }
getButterflyIndex()
in ButterflyMicroscopeMenu
just returns the Butterfly Index for the Scroll in the Microscope if there is one in the slot:
/** * Get the butterfly index of the current scroll. Used to render the * correct scroll. * @return The butterfly index. */ public int getButterflyScrollIndex() { ItemStack scroll = this.craftSlots.getItem(1); if (scroll != ItemStack.EMPTY) { if (scroll.getItem() instanceof ButterflyScrollItem scrollItem) { return scrollItem.getButterflyIndex(); } } return -1; }
Now the menu is fully functional in game and players can use the block to craft new Butterfly Books! With this change I’ve gotten rid of the hacky code that was originally used to craft these books, and replaced it with more robust code. I’ve also gotten rid of 170+ recipe files as well, since the block is smart enough to figure out the recipes for us.

A Finishing Touch
One small detail I didn’t like when developing the block was that it was hard to tell the Butterfly Books apart. While listing all the butterflies in the book is possible, it would only overflow on the screen and wouldn’t always help players tell books apart. I settled for just listing how many pages the book has. It’s not perfect, but at least you can see the new book has more pages than the old book when crafting.
To implement this I added an override for appendHoverText()
in ButterflyBookItem
:
/** * Adds some helper text that tells us what butterfly is in the net (if any). * @param stack The item stack. * @param level The current level. * @param components The current text components. * @param tooltipFlag Is this a tooltip? */ @Override public void appendHoverText(@NotNull ItemStack stack, @Nullable Level level, @NotNull List<Component> components, @NotNull TooltipFlag tooltipFlag) { String localisation = "tooltip.butterflies.pages"; int numPages = 0; CompoundTag tag = stack.getOrCreateTag(); if (tag.contains(CompoundTagId.PAGES)) { ListTag newPages = tag.getList(CompoundTagId.PAGES, 3); numPages = 2 * newPages.size(); } MutableComponent newComponent = Component.translatable(localisation, numPages); Style style = newComponent.getStyle().withColor(TextColor.fromLegacyFormat(ChatFormatting.DARK_RED)) .withItalic(true); newComponent.setStyle(style); components.add(newComponent); super.appendHoverText(stack, level, components, tooltipFlag); }
Now we can tell books apart without having to open them, and it should be useful enough to help players keep their books organised.