Butterfly Eggs Done Right

Early on in the mod’s development I made a couple of missteps. I was less experienced and didn’t fully understand how Minecraft works, so I made some design choices that have proven to be problematic. This week I spent some time correcting one of these mistakes, creating new entities that will improve both stability and cross-mod compatibility.

Butterfly Egg Blocks


In the original implementation of butterfly eggs, I decided to use modified leaf blocks to represent butterfly eggs being laid. This had led to a slew of problems.

  • Code Bloat
    We need a new class each time a new leaf block is added to Minecraft. You can already see this if you compare the 1.19 version to the 1.20 versions, since there are no Cherry Leaves blocks in 1.19.
  • Mod Compatibility
    If another mod uses a custom leaf block they write themselves, butterfly eggs won’t work with them unless someone writes some custom code. The mod author will likely not be motivated to support my mod, and I can’t be writing custom code for every single mod out there.
  • Swiss Cheese
    This has more to do with butterflies laying too many eggs, but at least one person has been forced to delete all the butterfly leaf blocks from their server, leaving them with trees that “look like Swiss cheese”.

We need a better way of doing this. Thankfully, there is a simple solution. All we need to do is combine a few things we have already done to create a new ButterflyEgg entity.

A New Entity


The new entity will combine behaviours from a few others. Firstly, it will be a simple cross section that sits on a leaf block similar to a chrysalis. Like the chrysalis, it will die if the leaf block is destroyed. It will also adopt behaviour from the caterpillar, this time dropping a butterfly egg if the player “breaks” it.

After that, the only thing we need to change is to make butterflies lay the new egg entities, rather than converting leaf blocks to butterfly leaf blocks.

The first thing we do is to copy the Chrysalis entity to create our new ButterflyEgg. We make a few changes to it so we can get the behaviour we want.

  • Scale
    We reduce the size of the eggs and remove the code that makes them grow. Butterfly eggs are very small
  • Hurt
    We copy the hurt() from the Caterpillar entity, but modify it so it drops a ButterflyEggItem instead.
  • Data
    Some minor details. We add a new eggLifespan for the entity to use (see below). We also update the textures to use the same texture as the item, and we save the resource location of the item it will drop.
  • Spawn
    The spawn code converts the egg to a chrysalis rather than a caterpillar.

After this we also need to add a new model and renderer for the entity. These are both essentially the same as the chrysalis model and renderer, though the model has been altered slightly to support the new texture.

With all this done, we can add our new entity and renderers to the EntityTypeRegistry. I’ve covered most of this code before so I won’t go into detail as I usually do, but you can see the new classes and their implementation on GitHub.

New Data


I mentioned above that the eggs also have a lifespan. In order to do this, I needed to update the ButterflyData to include the new attribute.

    // The lifespan of the caterpillar phase
    public final int eggLifespan;

    /**
     * Construction
     * @param entityId            The id of the butterfly species.
     * @param size                The size of the butterfly.
     * @param speed               The speed of the butterfly.
     * @param rarity              The rarity of the butterfly.
     * @param eggLifespan         How long it remains in the egg stage.
     * @param caterpillarLifespan How long it remains in the caterpillar stage.
     * @param chrysalisLifespan   How long it takes for a chrysalis to hatch.
     * @param butterflyLifespan   How long it lives as a butterfly.
     */
    public ButterflyData(int butterflyIndex,
                          String entityId,
                          Size size,
                          Speed speed,
                          Rarity rarity,
                          Habitat habitat,
                          int eggLifespan,
                          int caterpillarLifespan,
                          int chrysalisLifespan,
                          int butterflyLifespan) {
        this.butterflyIndex = butterflyIndex;
        this.entityId = entityId;
        this.size = size;
        this.speed = speed;
        this.rarity = rarity;
        this.habitat = habitat;

        this.eggLifespan = eggLifespan;
        this.caterpillarLifespan = caterpillarLifespan * 2;
        this.chrysalisLifespan = chrysalisLifespan;
        this.butterflyLifespan = butterflyLifespan * 2;
    }

    /**
     * Class to help serialize a butterfly entry.
     */
    public static class Serializer implements JsonDeserializer<ButterflyData> {

        /**
         * Deserializes a JSON object into a butterfly entry
         * @param json The Json data being deserialized
         * @param typeOfT The type of the Object to deserialize to
         * @param context Language context (ignored)
         * @return A new butterfly entry
         * @throws JsonParseException Unused
         */
        @Override
        public ButterflyData deserialize(JsonElement json,
                                         Type typeOfT,
                                         JsonDeserializationContext context) throws JsonParseException {
            ButterflyData entry = null;

            if (json instanceof final JsonObject object) {

                // <Snipped Code>

                JsonObject lifespan = object.get("lifespan").getAsJsonObject();

                String eggStr = lifespan.get("egg").getAsString();
                int eggLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(eggStr, "short")) {
                    eggLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(eggStr, "long")) {
                    eggLifespan = LIFESPAN_LONG;
                }

                String caterpillarStr = lifespan.get("caterpillar").getAsString();
                int caterpillarLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(caterpillarStr, "short")) {
                    caterpillarLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(caterpillarStr, "long")) {
                    caterpillarLifespan = LIFESPAN_LONG;
                }

                String chrysalisStr = lifespan.get("chrysalis").getAsString();
                int chrysalisLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(chrysalisStr, "short")) {
                    chrysalisLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(chrysalisStr, "long")) {
                    chrysalisLifespan = LIFESPAN_LONG;
                }

                String butterflyStr = lifespan.get("butterfly").getAsString();
                int butterflyLifespan = LIFESPAN_MEDIUM;
                if (Objects.equals(butterflyStr, "short")) {
                    butterflyLifespan = LIFESPAN_SHORT;
                } else if (Objects.equals(butterflyStr, "long")) {
                    butterflyLifespan = LIFESPAN_LONG;
                }

                entry = new ButterflyData(
                        index,
                        entityId,
                        size,
                        speed,
                        rarity,
                        habitat,
                        eggLifespan,
                        caterpillarLifespan,
                        chrysalisLifespan,
                        butterflyLifespan
                );
            }

            return entry;
        }
    }

I also modified the overall lifespan to take into account this new attribute. This will affect the butterfly books and the information they display.

    /**
     * Get the overall lifespan as a simple enumeration
     * @return A representation of the lifespan.
     */
    public Lifespan getOverallLifeSpan() {
        int days = (eggLifespan + caterpillarLifespan + chrysalisLifespan + butterflyLifespan) / 24000;
        if (days < 18) {
            return Lifespan.SHORT;
        } else if (days < 30) {
            return Lifespan.MEDIUM;
        } else {
            return Lifespan.LONG;
        }
    }

We also need to ensure this new attribute is sent to clients properly. I don’t want to make that mistake again, so we update the client bound data packet to include it.

    /**
     * Write the data to a network buffer.
     * @param buffer The buffer to write to.
     */
    @Override
    public void write(@NotNull FriendlyByteBuf buffer) {
        buffer.writeCollection(data, (collectionBuffer, i) -> {
            collectionBuffer.writeInt(i.butterflyIndex);
            collectionBuffer.writeUtf(i.entityId);
            collectionBuffer.writeEnum(i.size);
            collectionBuffer.writeEnum(i.speed);
            collectionBuffer.writeEnum(i.rarity);
            collectionBuffer.writeEnum(i.habitat);
            collectionBuffer.writeInt(i.eggLifespan);
            collectionBuffer.writeInt(i.caterpillarLifespan);
            collectionBuffer.writeInt(i.chrysalisLifespan);
            collectionBuffer.writeInt(i.butterflyLifespan);
        });
    }

And ensure the client reads the new attribute when it gets the packet.

    /**
     * Called when a custom payload is received.
     * @param event The payload event.
     */
    @SubscribeEvent
    public static void onCustomPayload(CustomPayloadEvent event) {

        // Handle a butterfly data collection.
        if (event.getChannel().compareTo(ClientboundButterflyDataPacket.ID) == 0) {

            // Extract the data from the payload.
            FriendlyByteBuf payload = event.getPayload();
            if (payload != null) {
                List<ButterflyData> butterflyData = payload.readCollection(ArrayList::new,
                        (buffer) -> new ButterflyData(buffer.readInt(),
                            buffer.readUtf(),
                            buffer.readEnum(ButterflyData.Size.class),
                            buffer.readEnum(ButterflyData.Speed.class),
                            buffer.readEnum(ButterflyData.Rarity.class),
                            buffer.readEnum(ButterflyData.Habitat.class),
                            buffer.readInt(),
                            buffer.readInt(),
                            buffer.readInt(),
                            buffer.readInt()));

                // Register the new data.
                for (ButterflyData butterfly : butterflyData) {
                    ButterflyData.addButterfly(butterfly);
                }
            }
        }
    }

Now we can define the lifespan of a butterfly’s egg just like all the other stages. As an example, our updated glasswing butterfly data looks like this:

{
  "index": 8,
  "entityId": "glasswing",
  "size": "medium",
  "speed": "moderate",
  "rarity": "uncommon",
  "habitat": "jungles",
  "lifespan": {
    "egg": "short",
    "caterpillar": "short",
    "chrysalis": "short",
    "butterfly": "long"
  }
}

Spawning Butterfly Eggs


Now we need to actually use these new entities. There are two places where we create butterfly leaf blocks: when a butterfly lays an egg, and when a player places one manually.

Before we start, we create a helper method in the ButterflyData to generate a butterfly egg’s resource location.

    /**
     * Gets the resource location for the butterfly egg at the specified index.
     * @param index The butterfly index.
     * @return The resource location of the butterfly egg.
     */
    public static ResourceLocation indexToButterflyEggEntity(int index) {
        String entityId = indexToEntityId(index);
        if (entityId != null) {
            return new ResourceLocation(ButterfliesMod.MODID, entityId + "_egg");
        }

        return null;
    }

For the butterfly we update the CustomServerAiStep() method so that it tries to create an egg entity rather than a butterfly leaf block.

                if (getIsFertile() && this.random.nextInt(320) == 1) {

                    // Attempt to lay an egg.
                    Direction direction = switch (this.random.nextInt(6)) {
                        default -> Direction.UP;
                        case 1 -> Direction.DOWN;
                        case 2 -> Direction.NORTH;
                        case 3 -> Direction.EAST;
                        case 4 -> Direction.SOUTH;
                        case 5 -> Direction.WEST;
                    };

                    BlockPos position = this.blockPosition().relative(direction.getOpposite());

                    if (level.getBlockState(position).is(BlockTags.LEAVES)) {
                        ResourceLocation eggEntity = ButterflyData.indexToButterflyEggEntity(this.butterflyIndex);
                        ButterflyEgg.spawn((ServerLevel)level, eggEntity, position, direction);
                        setIsFertile(false);
                        useEgg();
                    }

                } else {
                    // <Snip>
                }

The butterflyIndex is a new data member we add to the class, which we cache when the butterfly is created.

For the items, we need to update the registry so that it uses the new ButterflyEgg entity ID rather than the Butterfly.

    // Butterfly Eggs - Eggs that will eventually hatch into a caterpillar.
    public static final RegistryObject<Item> BUTTERFLY_EGG_ADMIRAL = INSTANCE.register(ButterflyEggItem.ADMIRAL_NAME,
            () -> new ButterflyEggItem(ButterflyEgg.ADMIRAL_NAME, new Item.Properties()));
    public static final RegistryObject<Item> BUTTERFLY_EGG_BUCKEYE = INSTANCE.register(ButterflyEggItem.BUCKEYE_NAME,
            () -> new ButterflyEggItem(ButterflyEgg.BUCKEYE_NAME, new Item.Properties()));
    // Etc.

After this, we do a simple copy/paste of the CaterpillarItem code, and modify it to create an egg entity instead.

    /**
     * If used on a leaf block will place the egg.
     * @param context The context of the use action.
     * @return The result of the use action.
     */
    @NotNull
    @Override
    public InteractionResult useOn(@NotNull UseOnContext context) {
        Player player = context.getPlayer();
        if (player != null) {

            BlockPos clickedPos = context.getClickedPos();

            Block block = context.getLevel().getBlockState(clickedPos).getBlock();
            if (!(block instanceof LeavesBlock)) {
                return InteractionResult.FAIL;
            } else {
                if (!context.getLevel().isClientSide()) {
                    Direction clickedFace = context.getClickedFace();
                    ButterflyEgg.spawn((ServerLevel) context.getLevel(), this.species, clickedPos.relative(clickedFace), clickedFace.getOpposite());
                } else {
                    player.playSound(SoundEvents.SLIME_SQUISH_SMALL, 1F, 1F);
                }

                context.getItemInHand().shrink(1);

                return InteractionResult.CONSUME;
            }
        }

        return super.useOn(context);
    }

There is one last place we can spawn butterfly eggs, and it is only possible because we are now using entities: natural spawns! By adding biome modifiers, we can now have eggs spawn in the world, just like all the other entities in the mod.

{
  "type": "forge:add_spawns",
  "biomes": [
    "minecraft:swamp",
    "minecraft:mangrove_swamp",
    "minecraft:forest",
    "minecraft:flower_forest",
    "minecraft:birch_forest",
    "minecraft:dark_forest",
    "minecraft:old_growth_birch_forest",
    "minecraft:windswept_forest",
    "minecraft:river",
    "minecraft:lush_caves"
  ],
  "spawners":
  {
    "type": "butterflies:admiral_egg",
    "weight": 10,
    "minCount": 3,
    "maxCount": 5
  }
}

Compatibility


If you look at the pull request you might notice something missing. Or more accurately, not missing. I haven’t removed any of the code for butterfly leaf blocks.

Although they can no longer be created after this change, that doesn’t mean they won’t exist in worlds already. If I was to remove them now, then the next time players update they would get errors when they load into their worlds. Worse than that, these blocks would be removed from their worlds, leaving all their trees looking like swiss cheese.

So for the sake of backwards compatibility the blocks will remain. Eventually, when I can be confident that most people have been playing an updated version for long enough, I will remove the old blocks. This way, players won’t load into broken error-filled worlds after my next update.

Now that we have the egg entities, they can be laid on any leaf blocks from any mod without any extra support needed. As long as the mod registers the blocks as leaves, all butterfly entities will work with them. This is a huge benefit, since most people that play modded Minecraft will use multiple mods, and the better they work together, the better the experience will be for the player.