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 × worldHeighttriple 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.

![[PUBG] Lessons from Building and Operating Steins.GG, a Match Analysis Web App with 80,000 Cumulative Users](https://blog.devkey.jp/en/posts/steinsgg-lessons-learned/index.png)


![[GUNDAM EVOLUTION] How to Get More FPS Even on a Low-Spec CPU](https://blog.devkey.jp/en/posts/gundam-evolution-fps-optimization/index.png)