LagMap
A server-side Minecraft mod that answers the question profilers usually dodge: which dimension, chunk and base is loading the server right now?
Tools like spark tell you what code is slow. LagMap tells you where in the world the work is. It samples every loaded chunk on a timer, ranks them by an estimated load score, and writes a snapshot you can read in chat, in JSON, or in a self-contained HTML report.
- Minecraft: 1.20.1
- Loader: Forge 47.x
- Side: server only (dedicated server or the integrated server in single player)
- Client mod required: no
What it does
- Tick timing. Measures MSPT every tick and keeps rolling 10s / 30s / 60s windows (average, min, max) plus a derived TPS figure.
- Per-dimension and per-chunk sampling. Every N seconds (default 5) it walks the
loaded chunks of every dimension and records:
- entity count, and how many of those entities are in an entity-ticking chunk
- block entity count, and how many of those have a ticker attached and sit in a ticking chunk
- whether the chunk is force-loaded (these bypass the score threshold, though an empty
one is still out-ranked out of the top-N list; the per-dimension
forceLoadedChunkCountalways reports the total) - the top entity / block-entity types in the chunk
- players within a configurable chunk radius
- A heuristic load score per chunk and per dimension (see the warning below).
- Export. A stable JSON snapshot plus a static HTML report, written atomically off the server thread.
What it does not do yet
- It does not measure real per-chunk or per-block-entity tick cost. See ROADMAP.md.
- No client GUI, no live web dashboard, no in-game map overlay.
- No mod-specific integrations (Compact Machines, FTB Chunks, claims, spark import).
- No historical time series - only the newest snapshot is kept on disk.
- Multi-version support is out of scope for this release; see Porting below.
The score is an estimate, not a measurement
estimatedScoreis a weighted count of what a chunk contains. A chunk full of ticking block entities scores high because such chunks usually cost tick time - but a single pathological entity can out-cost a thousand hoppers and LagMap will not see it. Use the score to decide where to look next, then confirm with spark. Every export repeats this disclaimer so it cannot be lost when a report is pasted into a bug tracker.
Commands
All commands require permission level 2 by default (configurable, see permissionLevel).
| Command | Effect |
|---|---|
/lagmap status |
MSPT windows, TPS, sampler state, and per-dimension totals from the last sample. |
/lagmap top [count] |
The hottest chunks server-wide from the last sample. count defaults to 10, max 100. |
/lagmap dump |
Samples immediately and writes latest.json + latest.html, regardless of config. |
/lagmap start |
Starts periodic sampling. |
/lagmap stop |
Stops periodic sampling. Tick timing keeps running. |
/lagmap tp <dimension> <chunkX> <chunkZ> |
Teleports you to the centre of that chunk. Player-only. |
Typical investigation:
/lagmap status # is MSPT actually high, and which dimension is heaviest?
/lagmap dump # take a fresh sample right now
/lagmap top 20 # which chunks dominate that dimension?
/lagmap tp minecraft:overworld 145 -302
/lagmap tp derives its Y coordinate from the surface heightmap, which is meaningful in
overworld-like dimensions and misleading in the Nether or in custom dimensions - it will
warn you. Teleporting loads the destination chunk, which is the one place LagMap causes
chunk loading.
Output files
Written to config/lagmap/ relative to the server directory:
| File | Contents |
|---|---|
latest.json |
The machine-readable snapshot. Stable key order, schemaVersion: 1. |
latest.html |
Self-contained report with the snapshot embedded. Double-click to open. |
Both are written to a temporary file and moved into place, so a viewer polling the JSON never reads a half-written document.
web/viewer.html in this repository is the same viewer with no data baked in: open it and
drop any latest.json onto it. Useful for reading a snapshot someone sent you.
Reading the JSON
{
"schemaVersion": 1,
"timestamp": "2026-08-20T12:00:00Z",
"chunkEnumeration": "chunkmap (complete: every fully-loaded chunk)",
"scoreFormula": "estimated (idleEntities*0.10) + ...",
"mspt": {
"last": 48.20,
"tps10s": 20.00,
"last10s": { "samples": 200, "average": 45.10, "min": 38.00, "max": 91.40 },
"last30s": { ... },
"last60s": { ... }
},
"totals": { "dimensions": 3, "loadedChunks": 1841, "entities": 2204, "blockEntities": 9033 },
"dimensions": [ { "dimensionId": "...", "totalEstimatedScore": 0, "hottestChunks": [ ... ] } ],
"topChunks": [ { "dimensionId": "...", "chunkX": 0, "chunkZ": 0, "estimatedScore": 0, ... } ]
}
Fields worth knowing:
chunkEnumeration- how loaded chunks were discovered.chunkmapmeans the list is complete.fallbackmeans chunks held open by an exotic mod ticket may be missing; treat the report as a lower bound.entityCountvstickingEntityCount- a chunk outside the ticking range still holds its contents but costs no tick time. A big gap between the two means "lots of stuff, not currently expensive".scannedChunkCountvsloadedChunkCount- these differ normally, and the gap is not a bug.loadedChunkCountis Minecraft's own count of chunk holders, which includes chunks that are only partially generated or sitting on the border of the loaded area.scannedChunkCountcounts the fully loaded chunks LagMap actually inspected - those are the only ones that hold entities and block entities. A gap only indicates truncation whenscannedChunkCountequalsmaxChunksPerDimension.sampleDurationMicros- how long LagMap itself took. Watch this; the tool must not become the lag.
Configuration
config/lagmap.toml, generated on first start. Highlights:
| Key | Default | Notes |
|---|---|---|
sampling.enabledOnStart |
true |
Start sampling automatically. |
sampling.sampleIntervalSeconds |
5 |
Lower is more responsive and more expensive. |
sampling.maxChunksPerDimension |
20000 |
Safety cap per dimension per sample. |
sampling.collectTypeBreakdown |
true |
Turn off on very large servers to cut sample cost. |
sampling.nearbyPlayerRadiusChunks |
8 |
Radius used to attribute players to a hot chunk. |
reporting.topChunksGlobal |
25 |
Hot chunks kept server-wide per snapshot. |
reporting.writeJsonEachSample |
true |
Set false to only write on /lagmap dump. |
reporting.permissionLevel |
2 |
Vanilla permission level required for /lagmap. |
scoring.*Weight |
see file | Weights for the heuristic score. |
Scoring weights default to idleEntity 0.10, idleBlockEntity 0.05, tickingEntity 1.00,
tickingBlockEntity 1.50. Idle contents are not weighted at zero because they still cost
memory, chunk saving and network bandwidth - just not tick time.
Overhead
LagMap is a lag tool, so it is built not to be the lag:
- Sampling is periodic, not per-tick. Only MSPT measurement runs every tick, and that is two
System.nanoTime()calls into a preallocated ring buffer. - Sampling reads only already-loaded state. It never forces a chunk to load and never touches
the disk. (
/lagmap tpis the sole exception, and only for the destination chunk.) - "Does this block entity tick?" is answered once per block state and memoised for the session.
- All file writing happens on a low-priority daemon thread.
sampleDurationMicrosin every snapshot tells you exactly what a sample cost.
Building
Requires a JDK to run Gradle. Minecraft 1.20.1 itself needs Java 17, which Gradle will download automatically via the foojay toolchain resolver if you do not have it.
./gradlew build
The mod jar lands in build/libs/lagmap-1.20.1-0.1.0.jar.
Gradle 8.14 supports Java 17 through 24. If your default java is newer, point Gradle at a
supported JDK:
JAVA_HOME=/path/to/jdk-21 ./gradlew build
Run a test server with ./gradlew runServer (accept the EULA in run/eula.txt on first
launch).
Porting to newer versions / NeoForge
Version-specific code is deliberately confined:
| Package | Depends on Minecraft? | Notes |
|---|---|---|
com.lagmap.model |
no | Plain records. |
com.lagmap.core |
no | Tick windows, sampling schedule. |
com.lagmap.score |
no | Scoring only. |
com.lagmap.export |
no | JSON + HTML. |
com.lagmap.platform |
no | The SnapshotCollector seam. |
com.lagmap.platform.forge1201 |
yes | The 1.20.1 implementation. |
com.lagmap.command, LagMapMod, LagMapConfig |
yes | Brigadier, Forge events, Forge config. |
A port means writing one new SnapshotCollector, re-wiring the entry point, and adapting the
command/config classes. Everything else moves unchanged.
The one piece of reflection in the mod lives in
platform/forge1201/LoadedChunkAccess.java: ChunkMap#getChunks() is protected and has no
public equivalent. It is resolved once at class-init (Mojang-mapped and SRG names are both
tried) and falls back to a public-API scan if that fails. The sampling path itself performs
no name lookups.
Project documents
| File | Purpose |
|---|---|
README.md |
What LagMap is and how to use it (this file). |
ROADMAP.md |
Planned work, ordered by value-to-effort. Long-term wishlist. |
MEMORY.md |
Current development state: what is proven, what is untested, what to do next. Updated every session. |
DECISIONS.md |
Why the code is the way it is. Append-only. |
CLAUDE.md |
Repo-specific guidance for AI coding agents. |
Licence
MIT.

