Skip to content
Go back
en

[Minecraft Mod Development] Cache Expensive Calculation Results

Published:
This article was migrated from steins.gg.

Because the .gg domain was expensive to maintain and I could no longer keep operating the site, steins.gg was shut down.

Good morning/afternoon/evening! Lately I’ve been hooked on Minecraft mod development!

When you are making Minecraft mods, you often run into processing that feels strangely heavy even though it just repeats the same calculation every time. For that kind of processing, simply caching the result once and reusing it can make the mod feel much lighter.

In this article, I will give a rough overview of topics like:

  • the basic way to think about writing caches in Java
  • cache libraries such as Guava / Caffeine
  • a simple way to cache data from a mod using Caffeine
  • Shadow configuration for bundling Caffeine into your jar

I will cover these with minimal code examples. This is an introductory article for mod developers who have managed to make a working mod for now, but want to do something about expensive processing.

Table of Contents

Open Table of Contents

Common Cache Mechanism Patterns

Hand-Rolled Cache with a Plain Map

The simplest approach is to write your own cache with Map from the Java standard library.

For example:

  • store results in Map<Key, Value>
  • get the value; if it is null, calculate it and then put it; otherwise return it as-is That kind of thing.

Pros

  • No external library required Cons
  • You have to implement all lifecycle management yourself, such as TTL, maximum size, and what to do under memory pressure
  • Once you start thinking about multithreading support and lock granularity, it becomes fairly painful

Use an External Library

These are the cache libraries you often hear about in the Java ecosystem:

  • Guava Cache
  • Caffeine (inspired by Guava Cache, positioned as a high-performance version)

Caffeine in particular uses a smart eviction algorithm called Window TinyLFU (W-TinyLFU), and its selling points are a high hit rate and strong efficiency. It is also used by well-known OSS projects such as Spring Boot, so it is one of the representative “modern cache libraries.”

So this time, I want to explain mod development using Caffeine.

Implementation Sample and Explanation

Version Without a Cache

First, here is an implementation with no cache at all.

The sample below scans all surrounding chunks and counts the number of each type of block when the player right-clicks the ground while holding a dirt block.

@Mod(
        modid = ChunkBlockScannerSampleMod.MODID,
        name = ChunkBlockScannerSampleMod.NAME,
        version = ChunkBlockScannerSampleMod.VERSION
)
public class ChunkBlockScannerSampleMod {
    public static final String MODID = "chunk_block_scanner_sample_mod";
    public static final String NAME = "chunk_block_scanner_sample_mod";
    public static final String VERSION = "1";

    private static final int SCAN_RADIUS_CHUNKS = 5;

    @EventBusSubscriber(modid = MODID)
    public static class Events {
        @SubscribeEvent
        public static void onRightClick(PlayerInteractEvent.RightClickItem e) {
            World world = e.getWorld();
            if (world.isRemote) return;
            if (e.getHand() != EnumHand.MAIN_HAND) return;

            EntityPlayer player = e.getEntityPlayer();
            ItemStack held = player.getHeldItemMainhand();
            if (held.isEmpty()) return;

            Block heldBlock = Block.getBlockFromItem(held.getItem());
            if (heldBlock != Blocks.DIRT) return;

            final long startMillis = System.currentTimeMillis();

            BlockPos clickPos = e.getEntityPlayer().getPosition();
            final int cpx = clickPos.getX() >> 4;
            final int cpz = clickPos.getZ() >> 4;

            Map<Block, Integer> total = new HashMap<>();

            for (int offsetChunkX = -SCAN_RADIUS_CHUNKS; offsetChunkX <= SCAN_RADIUS_CHUNKS; offsetChunkX++) {
                for (int offsetChunkZ = -SCAN_RADIUS_CHUNKS; offsetChunkZ <= SCAN_RADIUS_CHUNKS; offsetChunkZ++) {
                    int targetChunkX = cpx + offsetChunkX;
                    int targetChunkZ = cpz + offsetChunkZ;

                    Map<Block, Integer> part = countOneChunk((WorldServer) world, targetChunkX, targetChunkZ);
                    part.forEach((k, v) -> total.merge(k, v, Integer::sum));
                }
            }

            final long elapsedMillis = System.currentTimeMillis() - startMillis;
            final int chunkCount = (2 * SCAN_RADIUS_CHUNKS + 1) * (2 * SCAN_RADIUS_CHUNKS + 1);
            total.forEach((key, value) ->
                    player.sendMessage(new TextComponentString(key.getRegistryName() + " : " + value)));
            player.sendMessage(new TextComponentString(
                    String.format("Scanned %d chunks: elapsed=%dms", chunkCount, elapsedMillis)));
        }
    }

    private static Map<Block, Integer> countOneChunk(WorldServer ws, int chunkX, int chunkZ) {

        BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos();

        Chunk chunk = ws.getChunk(chunkX, chunkZ);
        final int chunkOriginBlockX = chunkX << 4;
        final int chunkOriginBlockZ = chunkZ << 4;

        Map<Block, Integer> countsByBlockMap = new HashMap<>();
        final int worldHeight = ws.getHeight();

        for (int offsetX = 0; offsetX < 16; offsetX++) {
            for (int offsetZ = 0; offsetZ < 16; offsetZ++) {
                for (int yLevel = 0; yLevel < worldHeight; yLevel++) {
                    mutablePos.setPos(chunkOriginBlockX + offsetX, yLevel, chunkOriginBlockZ + offsetZ);
                    Block b = chunk.getBlockState(mutablePos).getBlock();
                    countsByBlockMap.merge(b, 1, Integer::sum);
                }
            }
        }

        return countsByBlockMap;
    }
}

With this implementation:

  • no matter how many times you right-click the same place, it scans every chunk every time
  • as the number of chunks increases, the 16 × 16 × worldHeight triple loop starts to matter, and expensive processing runs on every execution (making the game heavy) That is the situation.

Add a “Per-Chunk Cache” with Caffeine

At this point, you can say, “The result for a chunk we already counted should probably be reusable for a while, right?” So we add a cache at the chunk level.

Cache design

  • Key: dimension ID + chunk X + chunk Z (example: 0-10-5)
  • Value: Map<Block, Integer> (the block composition of that chunk)

Caffeine implementation notes

  • get(key, k -> countOneChunk(...)) lets you write “if there is no cache entry, calculate and store it; if there is one, return the cached value immediately” in a single line
  • lifecycle management is also easy to implement with maximumSize, expireAfterAccess, and so on

The following diff shows the actual changes needed to implement the cache mechanism. You can insert a cache mechanism with only this small amount of code.

+    // Caffeine cache keyed by dimension ID + chunk coordinates
+    private static final Cache<String, Map<Block, Integer>> CHUNK_CACHE =
+            Caffeine.newBuilder()
+                    .maximumSize(512)  // Keep up to 512 chunks in this example
+                    .expireAfterAccess(60, TimeUnit.SECONDS) // Automatically evict if not accessed for 60 seconds
+                    .build();

  ~~~~~~~~
  ~~~~~~~~
-                   Map<Block, Integer> part = countOneChunk((WorldServer) world, targetChunkX, targetChunkZ);
+                   // Cache by "dimension ID-chunk X-chunk Z"
+                   Map<Block, Integer> part = CHUNK_CACHE.get(
+                       world.provider.getDimension() + "-" + targetChunkX + "-" + targetChunkZ,
+                       k -> countOneChunk((WorldServer) world, targetChunkX, targetChunkZ));

  ~~~~~~~~
  ~~~~~~~~

When developing mods, there are genuinely a lot of expensive processes where “the same input means the same result,” such as:

  • chunk scanning
  • pathfinding
  • preprocessing for structure generation If you make it possible to insert a one-line cache like this, both development and the user experience become much easier.

Even More Effective in Multiplayer Environments

By the way, this kind of cache does work properly in single-player, but it is even more useful in multiplayer environments.

If you target information shared between players, such as “world terrain” or “whether a structure exists,” as in this example, it works like this:

  • Single-player → Even if you call the process in the same place repeatedly, you can reuse the result calculated once
  • Multiplayer (server) → Results calculated by player A can later be reused as-is by players B and C

If you put the cache on the server side, then even when multiple players trigger expensive processing at the same time, you can make it behave like this:

  • “Only the first run does the real calculation”
  • “Everything after that returns immediately from the cache”

This can make a big difference to TPS stability and response time.

When Using Other Libraries, You Need to Bundle Them into the jar

Caffeine, used in this example, is an external library.

Libraries added with Gradle implementation / shade are on the classpath during development, but they are not automatically included in the final mod jar. Minecraft only loads the jars inside the mods folder, so if you do not bundle external libraries, users who try to use your generated mod jar will hit NoClassDefFoundError in their environment.

That is why you need to use something like the shadow plugin to create a fat jar (a jar that includes dependencies).

Below is an example of bundling everything into a single jar with the shadow plugin.

// Add the Shadow plugin to the build script's own classpath
buildscript {
    repositories { maven { url = "https://plugins.gradle.org/m2/" } }
    dependencies { classpath "com.github.jengelman.gradle.plugins:shadow:2.0.4" }
}

apply plugin: "com.github.johnrengelman.shadow"

configurations {
    shade
    // Make dependencies added to shade usable in the development environment too
    implementation.extendsFrom(shade)
    // Use this if your MDK uses compile
    // compile.extendsFrom(shade)
}

repositories {
    // For fetching Caffeine
    mavenCentral()
}

dependencies {
    // Add Caffeine as a shaded dependency
    shade 'com.github.ben-manes.caffeine:caffeine:2.9.3'
}

// For deobfuscated development
jar { classifier = "dev" }

shadowJar {
    classifier = ""
    // Avoid turning all other dependencies into a fat jar too
    configurations = [configurations.shade]
    // Relocate Caffeine so it does not conflict even if another mod bundles a different version
    relocate "com.github.benmanes.caffeine", "${project.group}.shadow.caffeine"
}

// Have ForgeGradle re-obfuscate shadowJar instead
reobf {
    shadowJar {}
}

build.dependsOn reobfShadowJar

Common utility libraries are often used by other mods as well, so the following problems tend to happen:

  • if you bundle them with the same package name as-is, classes collide
  • different versions may be loaded at the same time and cause crashes

By relocating the package, you can create a state where classes do not collide even if another mod bundles a different version of Caffeine, which makes it safer in multi-mod environments.

Summary

If your mod development has processing that is “heavy even though it performs the same calculation every time,” the quickest fix is to add a cache first. A Map is fine at the beginning, but once you start thinking about TTL, maximum size, and thread safety, it quickly becomes painful, so I recommend Caffeine.


  • Hytale QoL Mods That Improve Play Without Breaking the Vanilla Experience
  • [PUBG] Lessons from Building and Operating Steins.GG, a Match Analysis Web App with 80,000 Cumulative Users
  • Hytale Dedicated Server Settings to Make It More Stable and Less Laggy
  • Recreating Twitter's old video clip feature as a Chrome extension
  • [GUNDAM EVOLUTION] How to Get More FPS Even on a Low-Spec CPU

Previous Post
[Nostalgic] International Fitness YouTubers and Influencers from the Early YouTube Era [Updated Occasionally]
Next Post
How to Play PUBG LITE from Japan