Skip to content

Map Rendering

The map rendering system generates top-down world map images for in-game maps and can be used for external map viewers.

Each world has a WorldMapManager that handles tile generation and caching. It extends TickingThread and runs on its own thread (WorldMap - {worldName}).

WorldMapManager mapManager = world.getWorldMapManager();
MethodDescription
getImageAsync(x, z) / getImageAsync(index)Get or generate a map tile asynchronously
getImageIfInMemory(index)Get cached tile, returns null if not loaded
clearImages()Clear all cached tiles and discard pending generation
clearImagesInChunks(LongSet)Clear tiles for specific chunks
generate()No-op in current implementation
getGenerator()Get the current IWorldMap generator
setGenerator(IWorldMap)Replace the map generator
getWorldMapSettings()Get current WorldMapSettings
sendSettings()Push updated settings to all clients
isWorldMapEnabled()Whether the world map is enabled
getWorld()Get the parent world
addMarkerProvider(key, provider)Register a named marker provider
getMarkerProviders()Get all registered marker providers
getPointsOfInterest()Get current points of interest markers
// get map tile by chunk coordinates (loads from disk or generates if needed)
CompletableFuture<MapImage> future = mapManager.getImageAsync(chunkX, chunkZ);
future.thenAccept(image -> {
int width = image.width;
int height = image.height;
int[] pixels = image.data; // RGBA packed
});
// or by packed chunk index
long chunkIndex = ChunkUtil.indexChunk(chunkX, chunkZ);
CompletableFuture<MapImage> future = mapManager.getImageAsync(chunkIndex);

getImageAsync loads chunks from disk or generates them if needed — it does not return empty images for unloaded areas. The future resolves to null if the chunk store is shutting down or tile generation fails. Neighbor chunks (for edge shading) are also loaded, but missing neighbors default to flat shading.

The built-in ChunkWorldMap calls getChunkReferenceAsync with default flags (0), which means requesting a map tile for an unexplored area will generate that chunk. To only render explored chunks, check the loader’s on-disk index before requesting:

LongSet savedChunks = world.getChunkStore().getLoader().getIndexes();
long chunkIndex = ChunkUtil.indexChunk(chunkX, chunkZ);
if (savedChunks.contains(chunkIndex)) {
mapManager.getImageAsync(chunkIndex).thenAccept(image -> {
// render tile
});
}

getLoader().getIndexes() returns all chunk indices saved to disk without loading chunk data.

Custom provider: rendering only discovered chunks

getChunkReferenceAsync accepts a flags bitmask that controls loading behavior:

BitValueEffect
01Skip disk load (generate only)
12Skip generation (disk only — returns null for unexplored chunks)
24Mark chunk as TICKING after load
38Bypass loaded check (re-process even if already in memory)
416Enable “still needed” check (cancels generation if no player needs the chunk)

Common combinations:

FlagsEffect
0Load from disk + generate if needed (default)
2Disk only — returns null for unexplored chunks
3In-memory only (no disk, no generation)
9Force regeneration + skip disk (used by /chunk regenerate)

To only render discovered chunks, register a custom IWorldMap that skips generation:

public class DiscoveredChunksWorldMap implements IWorldMap {
private final WorldMapSettings settings;
public DiscoveredChunksWorldMap() {
UpdateWorldMapSettings packet = new UpdateWorldMapSettings();
packet.defaultScale = 128.0f;
packet.minScale = 32.0f;
packet.maxScale = 175.0f;
this.settings = new WorldMapSettings(null, 3.0f, 2.0f, 3, 32, packet);
}
@Override
public WorldMapSettings getWorldMapSettings() {
return this.settings;
}
@Override
public CompletableFuture<WorldMap> generate(World world, int imageWidth, int imageHeight, LongSet chunks) {
WorldMap worldMap = new WorldMap(chunks.size());
CompletableFuture<?>[] futures = new CompletableFuture[chunks.size()];
int i = 0;
for (long index : chunks) {
// flags = 2: load from disk only, skip world generation
futures[i++] = world.getChunkStore().getChunkReferenceAsync(index, 2)
.thenCompose(ref -> {
if (ref == null || !ref.isValid()) {
return CompletableFuture.completedFuture(null);
}
return ImageBuilder.build(index, imageWidth, imageHeight, world);
})
.thenAccept(builder -> {
if (builder != null) {
worldMap.getChunks().put(builder.getIndex(), builder.getImage());
}
});
}
return CompletableFuture.allOf(futures).thenApply(v -> worldMap);
}
@Override
public CompletableFuture<Map<String, MapMarker>> generatePointsOfInterest(World world) {
return CompletableFuture.completedFuture(Collections.emptyMap());
}
}
// swap the generator on your world
world.getWorldMapManager().setGenerator(new DiscoveredChunksWorldMap());

Chunks that were never saved to disk produce no tile — the client sees unexplored fog.

There are no per-chunk modification timestamps — to detect changes, listen to ChunkSaveEvent which fires for each chunk as it’s saved. Chunks are saved continuously in small batches, not all at once.

ChunkSaveEvent is an ECS event on ChunkStore, so you need an EntityEventSystem handler:

public class MapTileInvalidator extends EntityEventSystem<ChunkStore, ChunkSaveEvent> {
private final World world;
public MapTileInvalidator(World world) {
super(ChunkSaveEvent.class);
this.world = world;
}
@Override
public void handle(int index, ArchetypeChunk<ChunkStore> archetypeChunk,
Store<ChunkStore> store, CommandBuffer<ChunkStore> commandBuffer,
ChunkSaveEvent event) {
WorldChunk chunk = event.getChunk();
long chunkIndex = ChunkUtil.indexChunk(chunk.getX(), chunk.getZ());
LongSet changed = new LongOpenHashSet();
changed.add(chunkIndex);
world.getWorldMapManager().clearImagesInChunks(changed);
}
@Override
public Query<ChunkStore> getQuery() {
return WorldChunk.getComponentType();
}
}
// register in setup() — see /mod-lifecycle/#setup
this.getChunkStoreRegistry().registerSystem(new MapTileInvalidator(world));

See Chunk Saving for more details on the event and save timing.

There is no dedicated “chunk generated” event. ChunkPreLoadProcessEvent fires during chunk loading/generation, but it runs before the chunk is added to the store — so you can’t render it yet at that point. The isNewlyGenerated() method distinguishes generated chunks from disk-loaded ones.

The built-in map system doesn’t use events at all. Instead, it’s demand-driven — the per-player WorldMapTracker spirals outward from each player’s position and calls getImageAsync() for each tile coordinate. This approach naturally discovers new chunks as players explore.

For a custom external map renderer, two practical approaches:

Polling the on-disk index — periodically check which chunks exist on disk and render new ones. Start polling after StartWorldEvent, or guard against a null loader if scheduling early:

LongSet previouslyRendered = new LongOpenHashSet();
scheduler.scheduleRepeating(() -> {
var loader = world.getChunkStore().getLoader();
if (loader == null) return; // world not started yet
LongSet onDisk;
try {
onDisk = loader.getIndexes();
} catch (IOException e) {
return;
}
LongSet newChunks = new LongOpenHashSet(onDisk);
newChunks.removeAll(previouslyRendered);
for (long index : newChunks) {
renderChunk(index);
}
previouslyRendered.addAll(newChunks);
}, 100); // every 5 seconds

Using ChunkPreLoadProcessEvent — track generated chunks for later rendering:

// collect newly generated chunk indices (fires on async thread)
Set<Long> pendingRender = ConcurrentHashMap.newKeySet();
events.registerGlobal(ChunkPreLoadProcessEvent.class, event -> {
if (event.isNewlyGenerated()) {
pendingRender.add(event.getChunk().getIndex());
}
});
// render on a timer (chunk is in store by now)
scheduler.scheduleRepeating(() -> {
Iterator<Long> it = pendingRender.iterator();
while (it.hasNext()) {
renderChunk(it.next());
it.remove();
}
}, 100);

ChunkSaveEvent is useful for delta updates (re-rendering after block changes), while the approaches above handle initial rendering of new chunks.

// get cached image - returns null if not in memory, does NOT trigger generation
MapImage cached = mapManager.getImageIfInMemory(chunkIndex);
// clear all cached images
mapManager.clearImages();
// clear specific chunks
LongSet chunkIndices = new LongOpenHashSet();
chunkIndices.add(ChunkUtil.indexChunk(0, 0));
chunkIndices.add(ChunkUtil.indexChunk(0, 1));
mapManager.clearImagesInChunks(chunkIndices);

Cached tiles use a keep-alive counter: tiles visible to any player reset to 60, otherwise decrement each second. At 0 the tile is evicted. clearImages also drops in-flight generation futures — any pending results are silently discarded.

public class MapImage {
public int width;
public int height;
@Nullable
public int[] data; // max 4,096,000 pixels, may be null
}

Pixels are packed as RGBA:

int pixel = data[y * width + x];
int r = (pixel >> 24) & 0xFF;
int g = (pixel >> 16) & 0xFF;
int b = (pixel >> 8) & 0xFF;
int a = pixel & 0xFF;

The internal ImageBuilder reads chunk data from BlockChunk to produce each tile:

DataSourcePurpose
HeightmapBlockChunk.heightSurface Y level per column
TintBlockChunk.tintBiome color (pre-computed ARGB)
Block IDsBlockSection.chunkSectionSurface block type
Neighbor heights8 surrounding chunksEdge shading (Lambert lighting)

For each pixel: surface block color (BlockType.getParticleColor()) is combined with biome tint, shaded based on height differences to create terrain relief, and blended with fluid colors where water is present.

The map renderer is configured per-world via IWorldMapProvider, a JSON-configured type in the world config:

ProviderType IDDescription
WorldGenWorldMapProvider"WorldGen"Default - uses world generator’s map if available, otherwise falls back to ChunkWorldMap
DisabledWorldMapProvider"Disabled"Map disabled, returns empty images

ChunkWorldMap is the built-in renderer that generates tiles from loaded chunk data (heightmap, tint, block types from BlockChunk).

Each provider returns WorldMapSettings that control the client-side map:

FieldTypeDefaultDescription
worldMapAreaBox2DnullMap boundary (null = unlimited)
imageScalefloat0.5Pixels per block (ChunkWorldMap uses 3.0)
viewRadiusMultiplierfloat1.0Player view radius multiplier
viewRadiusMinint1Minimum view radius in chunks
viewRadiusMaxint512Maximum view radius in chunks

Register a custom provider type via the polymorphic codec:

IWorldMapProvider.CODEC.register("MyMap", MyMapProvider.class, MyMapProvider.CODEC);

The provider implements IWorldMapProvider.getGenerator(World) which returns an IWorldMap responsible for tile generation and points of interest.

Map markers show points of interest, players, and other tracked locations.

ProviderDescription
SpawnMarkerProviderWorld spawn point
PlayerIconMarkerProviderPlayer positions
DeathMarkerProviderDeath locations
RespawnMarkerProviderRespawn points
POIMarkerProviderPoints of interest
PerWorldDataMarkerProviderCustom world data markers
public class MapMarker {
@Nullable public String id;
@Nullable public String name; // display name
@Nullable public String markerImage; // icon asset
@Nullable public Transform transform; // position and rotation
@Nullable public ContextMenuItem[] contextMenuItems;
}
WorldMapManager mapManager = world.getWorldMapManager();
mapManager.addMarkerProvider("myMarkers", new CustomMarkerProvider());

The first parameter is a unique key for the provider.

MapMarkerTracker handles per-player marker visibility and updates. Players only see markers relevant to them (e.g., their own death marker, not others’).

Map tiles correspond to chunks:

  • Tile (0, 0) = Chunk (0, 0) = Blocks (0, 0) to (31, 31)
  • Each tile covers 32×32 blocks

For offline rendering, chunk data can be read directly from region files - see World Storage.