aerulion / Taerra

Status / In testing

Web map for Paper

Taerra

The whole world, in under three minutes.

A web map plugin for Paper servers. It reads the world straight from its region files, renders a top-down map and an isometric view from all four corners, and keeps both current while people play, without the server noticing it is there.

In testing on Corpium. A public release follows once it has held up under real players, real builds and real terrain.

The number

01 / 18

All of it, in 2:46.

Corpium’s main world has a 6,000-block radius. Taerra renders all of it, top-down and isometric from all four corners, at every zoom level. On the same machine, one of the most widely used map plugins needed more than sixteen hours for the same world, and drew less.

Full render
2:46
Established plugin
16 h+
Faster, wall-clock
>340×
Chunks
~560k
Same machine, same world
MeasureTaerraEstablished plugin
World6,000-block radius, up to 12,000 × 12,000 blocksSame
ViewsTop-down + isometric × 4 rotationsTop-down + one 3D view
Full render2 min 46 s16 h+
HardwareIntel Core Ultra 7 265, 64 GB RAMSame
More figures
MeasureValueNote
Top-down tilePending64 × 64 blocks
Isometric tilePending256 × 256 px
Render threadsPendingHalf the cores by default
Disk usagePendingFor the benchmark world
Main-thread cost~2 ms / tickDuring live refresh; zero during full renders

Principles

02 / 18

Five goals, eight refusals.

The goals have barely changed since the first line was written. What Taerra refuses to do matters as much: every refusal bought the renderer room to be fast.

Goals / 05

  1. 01

    A map, not a screenshot

    Readable terrain, clear relief, water that shows its depth. One flat colour per block, lit like a painting rather than shaded like a render.

  2. 02

    Both views are first-class

    The top-down map is for finding the way; the isometric view is for showing off what people built. Neither is bolted on, and both have to read as the same map.

  3. 03

    The server must not notice

    No chunk loading, no chunk generation, no unbounded work on the main thread. A full render of a large world is something to start on a Tuesday afternoon, not schedule for 4 a.m.

  4. 04

    Nothing changed, nothing costs

    A creeper crater redraws that hill, not the region around it. A re-render that comes out byte-identical is never downloaded again.

  5. 05

    Correct by construction

    Block colours and shapes come from the game’s own assets, not from a hand-kept spreadsheet that drifts with every Minecraft update.

Refusals / 08

No 3D, no texturesPNG tiles
No WebGL either: the map is plain image tiles. Any browser on any phone can show it, and the server does the work once instead of every client doing it every frame.
No real geometryFull cubes
Slabs, stairs, walls, fences and panes all draw as cubes. At 4–16 px per block a stair’s step is noise, and the stylised cube is why a chunk fits in ~19 KB.
No style togglesOne look
Lighting, sun angle and palette are fixed. The style is part of the product, and no configuration means no combinations to test and no cache keys to multiply.
No per-face shadowsPer column
Shadows are decided per column top-down, and per run and face band in isometric, never per pixel. There is no block light either: night mode and torchlight are on the someday list, not the roadmap.
No on-demand renderingFile reads
A tile request is a file read. A tile that does not exist yet answers “nothing here”, and the pipeline catches up when it gets there. Anyone who can open the map can ask for any tile, so no request may buy CPU time.
No realtime by default15 min
Changes are recorded instantly and rendered on an interval. A tile edited four hundred times in that window renders once.
No accountsIn game
No passwords and no web admin panel. A Minecraft account is the identity, and the server is configured from inside the game.
No outside servicesOne jar
No external database, no CDN, no Folia for now. One jar with an embedded web server, and tiles as plain files on disk.

Pipeline

03 / 18

Every arrow moves less.

Two lanes feed one renderer. A full render walks the region files on disk; a live refresh takes chunks the server already holds in memory. Both end in the same sampler, the same cache and the same encoder.

Lane 01Full render10 stages

  1. 01Header scanWhich chunks exist, without reading one.4 KiB / region
  2. 02Z-order planNeighbouring regions render close together.Morton curve
  3. 03Selective readEverything else is skipped in the stream.4 NBT fields
  4. 04Run samplerSurfaces kept, interiors swallowed.256 columns
  5. 05Column cacheOne structure behind both renderers.~19 KB / chunk
  6. 06Top-downMeasures the heights isometric needs.64 × 64 blocks
  7. 07IsometricQueued from those heights, right behind.4 rotations
  8. 08Zoom pyramidTop-down composed in memory from tiles still held.64 → 1
  9. 09Indexed PNGUnchanged bytes stop here.FNV-1a hash
  10. 10Disk and socketServed to Leaflet, pushed as tile_updated.LRU / Javalin

Lane 02Live refresh05 stages

  1. 01Block eventsOnly tiles that could look different.Tile maths
  2. 02Change bufferDeduplicated: many edits, one render.15 min
  3. 03Live laneRate-limited, so a crater cannot starve a full render.Token bucket
  4. 04Chunk snapshotFrom memory, since the region may not be saved yet.2 ms / tick
  5. 05Run samplerSame sampler, same cache, same encoder.Joins at 04

Reading the world

04 / 18

Read the disk, not the server.

Most map plugins either ask the server for chunks, which loads them and competes with players for the main thread, or run a second process with its own copy of the world. Taerra reads the Anvil region files directly, from its own threads, while the server keeps running.

Read per region to plan
4 KiB
To plan the main world
~2.3 MB
Reads per chunk, per view
1
NBT fields kept
4

The header scan

A region file stores 32 × 32 chunks, and its first 4 KiB is a table of 1,024 sector offsets. A zero offset means the chunk was never generated. Planning a full render reads only those tables: for Corpium’s main world, some 550 to 600 files and about 2.3 MB. Before a single chunk is decoded, the plugin knows exactly which tiles exist, and it never touches the ones that do not.

Java / The header scan
for (int index = 0; index < 1024; index++) {
  if (header[index * 4] == 0 && header[index * 4 + 1] == 0 && header[index * 4 + 2] == 0) {
    continue; // chunk never generated
  }
  zoom0Tiles.add(TileGeometry.chunkToTile(regionX * 32 + index % 32, regionZ * 32 + index / 32, 0, world));
}

Only the bytes that matter

A chunk’s NBT carries entities, block entities, heightmaps, light, structure references, ticking lists and POI data, and none of it changes how the map looks. Each chunk is read with one positional read and parsed through Minecraft’s own streaming visitor, set to collect four fields and skip the rest without building an object for any of it.

Chunks still at a proto stage are dropped, so half-generated terrain at the edge of the world never reaches the map. Oversized chunks stored beside the region and every compression scheme the game supports go through the game’s own code, and a read that collides with the server writing the file reopens the region and tries once more.

Java / Four fields, the rest skipped
CollectFields fields = new CollectFields(
    new FieldSelector(ListTag.TYPE, "sections"),
    new FieldSelector(IntTag.TYPE, "xPos"),
    new FieldSelector(IntTag.TYPE, "zPos"),
    new FieldSelector(StringTag.TYPE, "Status"));
NbtIo.parse(input, fields, NbtAccounter.create(64L << 20));

Sections, decoded lazily

PaletteOnce
Resolved to materials and a parallel air flag, once per section.
All airPalette only
A section whose palette holds only air is skipped without unpacking a single index.
All fullPalette, lazily
If every palette entry is a full cube, the section is solid throughout. The next chapter is why that matters.
IndicesOn first touch
Unpacked into a short[4096] once, then indexed directly. Sections the sampler never touches stay packed.
BiomesOne at a time
Unpacked per lookup, because the sampler only ever asks for one or two per column.

Columns as runs

05 / 18

Surfaces, not blocks.

A chunk holds 98,304 blocks and the map cares about very few of them. Each of its 256 columns is sampled from the top down into runs: vertical stretches of one full-cube material. Everything else rests on this.

Blocks per chunk
98,304
Columns sampled
256
Cached per chunk
~19 KB
For 8,192 chunks
~160 MB
90air74RUN 0 / oak_leaves / 74..7170air67RUN 1 / grass_block / 6766RUN 2 / dirt / 66..6362RUN 3 / stone / 62..-64-64
One column, four runs

Ore veins and caves nobody can see have been folded into the stone. Drawn heights are schematic.

Stored runs
MaterialTopBottom
oak_leaves7471
grass_block6767
dirt6663
stone62-64
Structure of arrays, no object per run
FieldTypeHolds
runOffsetsint[257]Column i owns runs off[i] up to off[i + 1]
runTopYshort[]Top of each run
runBottomYshort[]Bottom of each run
runMaterialMaterial[]One material per run
capMaterialMaterial[]Per column: the flower, grass or snow painted on top
columnBiomeBiome[]Per column
minTopY, maxTopYint, intPacked into one long for the height index

Three shortcuts

  1. 01

    All-air sections, skipped whole

    One mask jumps to the section base. Sky, floating islands and the air above an ocean cost one palette check each.

  2. 02

    All-full sections, swallowed whole

    In an interior column whose run reaches the top of a section built only of full cubes, every block is enclosed on six sides and can never be seen. The run grows by sixteen without reading a block, and most deep terrain goes this way.

  3. 03

    Hidden runs, merged after

    A last pass folds each interior run whose four neighbours are solid across its full height into the run above. Ore veins and sealed caves vanish into one stone run, and four cursors that only ever move down keep the pass linear.

Java / Sixteen blocks, zero reads
if (interior && (y & 15) == 15 && runMaterial != null && !Fluids.isFluid(runMaterial)
    && runBottomY == y + 1 && source.isSectionAllFull(y, shapes)) {
  y &= ~15;
  runBottomY = y;   // absorb 16 blocks, zero reads
  continue;
}

Thin things and ground cover

Ground coverCap material
Flowers, grass, carpets and snow layers up to two tall colour the top face beneath them. A meadow reads as a meadow and keeps its relief.
Tall stacksRuns
Sugar cane and tall bamboo become runs of their own, so they stand up in the isometric view.
Submerged plantsWater
Seagrass and kelp are sampled as water, so a kelp forest does not punch holes in the ocean.
The NetherUnder the roof
In a world with a ceiling the column top is the first solid block under two blocks of open space, so the map shows cavern floors instead of one bedrock rectangle.

The height index

Every sampled chunk leaves its lowest and highest top behind, packed into one long in a per-world map. It costs 16 bytes per chunk, is never evicted, and outlives the cache that produced it.

Three systems use it to skip work. The isometric renderer culls chunks that cannot reach a tile before loading them; shadow and occlusion rays stop once they rise above the highest terrain nearby; and invalidation bounds a block change vertically, so an edit at y = 64 does not dirty tiles for the whole build height.

Top-down

06 / 18

One pixel per block, read as ground.

A top-down tile is 64 × 64 blocks at one pixel each, stored at native size and scaled up in the browser without smoothing. A flat colour per block reads like a spreadsheet, so four cheap passes make it read like terrain.

Four passes

TerracingDrop ≤ 8
Where a block drops away to +X or +Z, its side colour, lit as the matching isometric face, is blended in by the height of the drop. Cliffs pick up the brown of a grass block’s side, and the map becomes the isometric view seen from straight above.
Occlusion1.00 → 0.76
The number of taller neighbours among the eight around a block sets an occlusion factor. Valleys darken and ridges stand out.
Sun shadows≈ 26.6°, 32 steps
Traced through the height field in fixed point: two multiply-shifts and a lookup per step, no floating point and no trigonometry.
Water depth4-block bands
Shaded from the bed’s height field rather than the flat surface, then tinted in depth bands that push deep water toward blue.
Terracing / One blend per pixel
colour = (top·2 + sideX·dropX + sideZ·dropZ) / (2 + dropX + dropZ)
Java / The sun, in fixed point
static final int SUN_STEP_X = 809, SUN_STEP_Z = -627;   // ≈ unit vector × 1024
// step k samples (x + k·809 >> 10, z − k·627 >> 10) at height topY + ⌈k/2⌉

Isometric

07 / 18

Decide what not to draw.

The classic 2:1 pixel-art projection, 8 px per block at zoom 0, three flat polygons per block. The renderer is fast not because it draws polygons quickly, but because most of them are never drawn at all.

TOP+Z / SKY+X / SUN, SKY
Three faces

More light, more ink. Drawn at 30° to stay on the lattice; the renderer’s projection is 2:1.

SKYLINESKIPPEDDRAWN
The skyline

Below the line every pixel is painted. Whatever falls wholly under it is never loaded.

SUM / DIFFERENCE
Candidates

Solved from the tile’s bounds, not searched for. No chunk outside is ever tested.

Front to back, write once

The painter’s algorithm draws back to front and lets near things overwrite far ones. In a dense world nearly everything it draws is overwritten, so nearly all of its work is wasted.

Taerra draws front to back, and a pixel that is set is never written again. In this projection nearer simply means a larger x + z in rotated space, so chunks are sorted by that sum and columns walk the anti-diagonals from 30 down to 0. No depth buffer, and no sort per pixel or per block.

Java / Write once
void paint(int index, int color, int depth, int blockY, int submergedExtra) {
  if (pixels[index] != 0) return;  // something nearer already owns this pixel
  ...
}

The skyline

Write-once pixels only save the writes. The real saving is knowing, before doing any work, that something is hidden. For every screen column the renderer keeps the row from which each pixel down to the bottom of the tile is already painted: the skyline. Whatever lies farther away, with its whole footprint under that line in every column it touches, cannot be seen. After each face the line is pulled up through pixels that were already set, so coverage from separate objects joins up.

Culled at three levels

  1. 01

    Per tile

    Once every screen column is painted from the top row down, the tile is finished and the chunk loop ends.

  2. 02

    Per chunk

    A chunk’s screen bounds come from its known highest top, not the world height. If the whole footprint sits under the skyline, the chunk is never fetched from the cache or read from disk.

  3. 03

    Per run

    Walking down a column, the first run whose top row is already covered ends it. Everything below is hidden too.

The rest of the renderer

Candidates, solvedSum and difference
A tile’s screen x-range fixes a range of rotatedX − rotatedZ, and its y-range, with the world’s height range, fixes a range of their sum. The chunks that can land on it are exactly the points where the two share a parity: a tight diamond, no wasted tests, narrowed further per chunk by the height index.
Exposed faces onlyCursor walk
A side face is drawn only where the neighbouring column leaves it exposed, so a wall beside a hill is drawn from the hilltop up. Rows that come out the same colour merge into one span.
Water to see into0.8 · (1 − 0.5ᵈ)
Water wets pixels without claiming them, and whatever solid is drawn behind is blended by depth. Reefs glow turquoise, trenches fade to navy, and a sunken build shows through.
Four rotationsOne renderer
Rotation is a transform applied at the edges; the renderer only ever sees rotated space. Each rotation is its own pyramid on disk, rendered, invalidated and served on its own.

Light

08 / 18

Expensive maths, computed once.

Trailer lighting: a warm sun and a cool sky, mixed in linear light and pushed through OKLab with a 1.2× chroma boost, so shadows go blue-violet instead of muddy grey. Three cube roots, three powers and three sRGB encodes per colour: far too much to spend per pixel.

The inputs, though, are tiny and discrete: a colour, one of three faces, up to eight taller neighbours or blocked rays, shadowed or not, and one of three contact levels. All of them pack into a single long, and a hash map per thread remembers the answer.

After the first few tiles the hit rate is effectively 100%, and lighting costs one lookup per face. Zero doubles as the missing sentinel for free, because a lit opaque colour always carries full alpha.

Cache key / 41 bits of one long

  1. 32ColourARGBBit 0–31
  2. 2FaceTop, +X, +ZBit 32–33
  3. 4Taller0–8Bit 34–37
  4. 1ShadowYes / noBit 38
  5. 2Contact3 levelsBit 39–40

Occlusion and shadows

Occlusion per run8 compares
Neighbouring bed heights are gathered once per column and compared against each run’s top, so the floor of a shaft is darker than the ground above it.
Enclosure8 rays, 12 blocks
Side faces cast eight short rays from the air in front of them and count the hits. Alleys and courtyards darken on their own.
Contact1–2 rows
Where a wall meets the ground its lowest rows get a darker sky term, which grounds a building without real occlusion.
Sun shadowsThrough the runs
Marched through the full run data rather than the height field, so overhangs cast their shadows correctly.

One cut-off for every ray

Before a chunk is rendered, the renderer looks up the highest terrain its rays could reach in the height index, and every ray stops as soon as it rises above it. In open terrain, which is most of any world, a shadow ray gives up after a step or two instead of marching all thirty-two.

Scheduling

09 / 18

The order is the optimisation.

A fast renderer still makes a slow render if it reads the same data three times. Much of the speed is the order in which things happen, which is the part nobody draws diagrams of.

Tiles per full-render batch
64
Tiles per region
8 × 8
Dispatcher tick
20 ms
Live batches / s
10

Five decisions

  1. 01

    Batches follow region files

    A batch is a seed tile and up to 63 more of the same priority, world, view and rotation in the same region; live batches stop at 16. A region is 512 × 512 blocks, exactly 8 × 8 tiles: one open file, one hot set of chunks, one worker.

  2. 02

    The world walks a Z-order curve

    Inside a region, tiles run back and forth like an ox ploughing. The regions themselves are sorted by Morton code, so neighbours render close together in time and the halo one sampled is still cached when the next one needs it.

  3. 03

    Isometric follows the ground

    Early versions queued isometric tiles for the full height range and produced three to four times more tiles than had anything in them, in each of four rotations. Now they are queued from the heights their chunks were just measured at, per chunk rather than per tile, and pushed to the front, so they render right behind top-down while those chunks are still cached.

  4. 04

    The pyramid is built in memory

    A finished top-down batch composes its own zoom levels from the images it still holds, 64 to 16 to 4 to 1, and queues only the top. Isometric and live tiles queue their parents one by one. Point sampling keeps the pixel art crisp and never invents a colour that was not in the source.

  5. 05

    Two pools that share

    A base pool and a zoom pool, half the cores by default and at least two left for the server. An idle worker takes the other pool’s work, and a finished batch dispatches at once instead of waiting for the next tick.

Four queue lanes
LaneWorkNote
Live, zoom 0Tiles a change touchedToken bucket: 10 batches / s, 2 s burst
Full render, zoom 0Every tile that existsRegion batches on the Z-order curve
Live, parentZoom levels above a changeRecomposed from the tiles below
Full render, parentAbove each pyramid topOne zoom task per finished batch

Nothing lost

A tile dirtied again while it is rendering is remembered and re-queued the moment the current render lands, so no change is lost to a race. On shutdown the whole queue, in-flight tiles included, is written atomically to JSON, and a restart picks up where it stopped.

Live updates

10 / 18

Small, bounded, batched.

When a block changes, a listener at MONITOR priority works out exactly which tiles could look different. Nothing else is touched, and nothing is drawn until the interval comes round.

Default interval
15 min
Main-thread budget
2 ms
Checks per tick, max
4,096
Shadow halo, blocks
32

From block to tile / 04 steps

  1. 01

    Work out the tiles

    Top-down: the column’s tile, a one-block halo for occlusion and terracing, and 32 blocks toward the sun, because a new tower casts its shadow that way. Isometric: the chunk’s footprint in each rotation, bounded by the height index and the changed range, extended by the shadow’s reach.

  2. 02

    Buffer and deduplicate

    Tile coordinates collect in a set that flushes into the live lane once per interval. However often a tile changes in that window, it renders once.

  3. 03

    Snapshot from memory

    The region file may not be saved yet, so changed chunks are captured on the main thread under a 2 ms budget, then sampled on a worker by the same sampler into the same cache. Chunks no longer loaded simply drop out and are read from disk later.

  4. 04

    Wait out pre-generation

    Freshly populated chunks are marked for rendering, unless a Chunky pre-generation run is in progress. Then Taerra leaves them alone rather than chasing thousands of chunks a second, and when Chunky stops it offers staff a full render in chat.

Writing less

11 / 18

Same bytes, no write.

The cheapest write is the one that never happens. Every stage after the renderer exists to find out whether anything actually changed.

Smaller before deflate
4×
Hash, doubling as ETag
64-bit
Update coalescing
500 ms
Tiles before bulk
256

Where a write stops

Indexed PNGHand-rolled
A flat-shaded tile rarely has more than a few hundred colours. The encoder builds its palette on the fly, writes transparency only when it is needed and deflates 8-bit scanlines: a quarter of the raw data before compression even starts. Past 256 colours it falls back to ImageIO.
Content hashFNV-1a
Every encoded tile is hashed, and the hash is its ETag. When a re-render matches the file on disk, which happens constantly, nothing is written and no client is told. Only the file’s modified time moves.
Mark and sweepThe filesystem
That modified time is the garbage collector. After a full render, any tile older than the render’s start was not produced by it, belongs to terrain that no longer exists, and is deleted. No manifest, no database. If any region could not be read, the sweep is skipped.
Client updatesCoalesced
Changed tiles are announced every 500 ms. Past 256 at once a single bulk refresh goes instead, at most every 5 s per world, and the client cache-busts only what changed.

Pick buffers

12 / 18

The block under the cursor.

Top-down, a cursor maps straight to world coordinates. In isometric a screen point is a line through the world, and guessing sea level is right on flat ground and wrong everywhere else. The renderer knows the answer, because it wrote every pixel.

The trick

One number per pixel is enough. Screen x narrows u − v down to two neighbouring candidates; screen y ties u + v to the height. Recording the depth u + v settles both, because it always shares its parity with u − v, and the two candidates differ in theirs.

Two planes, one buffer

Depth planeu + v
Recovers the block under every pixel from a single number.
Height planeKept anyway
A wall pixel could belong to either of two levels, and teleporting someone into the wrong one is worse than a few extra bytes.
Storage16-bit, planar
Offsets from each plane’s minimum, at half resolution for isometric tiles, deflated. A depth plane is a smooth gradient and a height plane is a height map, so both compress well.
DecodingNative
The browser inflates them with DecompressionStream, with no JavaScript inflate library. They drive the block tooltip, the coordinate readout and click-to-teleport.

The frontend

13 / 18

A map people leave open.

The renderer is half the product. The other half is a tab people keep on a second monitor, send links to and use to find their friends: Leaflet, plain ES modules and no build step, so what is in the jar is what the browser runs.

In the browser

The URL is the stateShareable
World, position, zoom, view, rotation, panels, colour style, language and layers all live in the fragment, debounced so the back button is not flooded. Paste a link in Discord and the other person sees exactly what you see.
Context menuKeyboard too
Copy the coordinates or a link, centre the map, teleport or drop a waypoint. In isometric the coordinates are exact, height included. Arrow keys move through it and Escape closes it.
Grey styleOne key
A second colour style maps brightness onto the interface’s stone palette so markers stand out. It is an SVG filter: one set of tiles on the server, two looks in the browser, nothing rendered twice.
Live, when it changedOne socket
One WebSocket per tab carries player positions, sampled once a second and sent only when something changed, plus tile updates, marker changes and render progress. An empty server at night sends nothing, and a client that stops reading loses updates rather than growing the server’s buffers.
Markers and layersMarker API
WorldGuard regions, vanilla and ChunkyBorder borders, grouped waypoints and spawn, each a layer a viewer can switch. Other plugins add their own through a small API, drawn on the ground they belong to, and markers from another web map can be imported.
Self-hostedEN / DE
Fonts, icons and player heads are all served by the plugin itself, and the interface speaks English and German.

Players, and their privacy

Player markers show the player’s face in the colour of their in-game locator bar, and following someone keeps them centred, even across a world switch. Heads are fetched by the server, only from textures.minecraft.net, then cropped, cached and served locally, so looking at the map never leaks a viewer’s address to Mojang or anyone else.

Who shows up

  1. 01

    Hidden

    Players in spectator mode, invisible or vanished players, and anyone with taerra.hide do not appear at all.

  2. 02

    Anonymous

    Players who turned off server listings in their client appear as a grey dot: no name, no head, and an id salted afresh on every server start, so they cannot be followed across restarts.

  3. 03

    Staff

    Staff with taerra.web.see-hidden do see hidden players, clearly marked as hidden.

Access

14 / 18

No passwords, no admin panel.

Some of the map is only for some people, which means knowing who is looking. That takes no accounts, no passwords and no e-mail addresses: a Minecraft account is the identity, and the login starts in game.

Logging in / 05 steps

  1. 01

    A link in chat

    /taerra login answers with a clickable link carrying a one-time code: 32 random bytes, valid for five minutes, usable once. A link that leaks after it has been clicked is worthless.

  2. 02

    Carried in the fragment

    The code rides after the #, which browsers never send in a request or a Referer. The page strips it from the address bar before doing anything else, so it stays out of the history too, and then posts it to the server.

  3. 03

    A thirty-day session

    The code is exchanged for a 30-day session cookie: HttpOnly, SameSite=Strict, and Secure over HTTPS, including behind a reverse proxy.

  4. 04

    Told in game

    The player is told a browser has just logged in as them. Other open tabs hear about it over a BroadcastChannel and never need a reload.

  5. 05

    Hashes only

    Only SHA-256 hashes of codes and tokens are stored, so a copy of the sessions file logs nobody in. The login goes to the command’s sender, not its executor, so it cannot be minted on someone else’s behalf. /taerra logout ends a player’s sessions, and staff can end anyone’s.

Permissions that follow the player

Permission plugins cannot reliably say what an offline player may do, and people browse the map offline all the time. So while a player with a web session is online, Taerra checks the handful of permissions the map cares about once a second and keeps the result beside the session. Offline, their browser goes by that snapshot.

When a snapshot changes, every open connection that player has is re-checked at once. Remove a staff rank in LuckPerms and within about a second their open tab loses the staff world and the private layers, without a reload.

Every world-specific endpoint, from tiles and pick buffers to markers, players and avatars, passes the same check, and a private world answers exactly like one that does not exist: a plain 404.

From the browser
ActionNeedsDetail
Browse public worlds and layersNothingNo login needed
See a private worldtaerra.web.world.<ns>.<world>e.g. minecraft.the_end
See a private layertaerra.web.layer.<layer>e.g. worldguard
See hidden playerstaerra.web.see-hiddenMarked as hidden
Teleport from the maptaerra.web.teleportOnline, onto the exact block shown
Edit waypointstaerra.command.waypointWorks while offline
Private waypoint groupstaerra.command.configPrivate before the first waypoint

Writing from the browser

TeleportsChecked live
The snapshot only decides whether the button shows. The permission is checked again on the main thread, the target must lie inside the border, the chunk loads asynchronously, and the height comes from the pick buffer, so you land on the roof you clicked.
WaypointsFrom a phone
The sixteen dye colours in creative-inventory order, a hue and shade picker and a hex field. Move one by picking it up and clicking its new spot, or put it in a group that becomes its own layer. Editing goes by the permission snapshot, so it works from a phone while offline.
Every writeSame origin
Guarded by Fetch Metadata. A browser too old to send it is refused, and only a client with no Origin at all gets through without it. Names are validated and nothing can be placed outside the border, so no request can make the server load or generate a chunk it should not.

No admin panel

What the browser cannot do is administer the plugin, and that is deliberate. Enabling worlds, making worlds and layers private, limiting the rendered area and starting renders all happen in game, through /taerra config, /taerra limit and /taerra render, with tab completion. The same render command also exports a world as one PNG per view and rotation, stitched from the tiles on disk and streamed a row of tiles at a time, so even a whole world never has to fit in memory. A web admin panel would be another login surface to secure, for settings changed once.

Colours

15 / 18

Taken from the game itself.

The last piece runs before the plugin is even built. A small Python tool generates the block data from the vanilla client assets for the target version, and nothing in it is maintained by hand.

Blocks in the data
1,187
Side unlike the top
532
Voxel grid per model
16³
Shape threshold
0.25

Composited, then measured

For every block the tool resolves the blockstate to a canonical variant, flattens the model’s parent chain, resolves its texture references and composites the outward faces in element order, which is what puts the green fringe on top of a grass block’s dirt side.

How the data is made

Picked, not averagedOKLab mean shift
Pixels are clustered in OKLab and the dominant cluster wins, so outlines, highlights and stray pixels cannot drag a colour toward mud.
Top and side532 of 1,187
Kept apart. Derive the side by darkening the top and a grass block renders as green dirt.
Shapes, measuredFlood fill
Each model is voxelised with the union of its rotated placements, and a sheet thinner than one unit counts only if it encloses a volume. Mature wheat stays thin, a hopper is full, and powder snow comes out solid, with no per-block exceptions. The line itself, 0.25, is exactly a wall post, so walls count and fence posts, at 0.0625, do not.
Panels as wallsForced full
Doors, trapdoors, panes and fences count as full, so a glass facade does not render as a hole into the building behind it.
TintsBaked or flagged
Constant tints are baked in. Only biome-dependent faces keep a flag, resolved per biome at runtime, the swamp’s grass noise included.
Fail loudlyMagenta
A block missing from the data renders as a magenta cube, and at startup the data is cross-checked against the running server.

The log

16 / 18

What did not pay off.

Honest notes from the development log, and the four things still on the list.

Tried and dropped / 04

  1. 01

    Caching shadows across a batch

    A column on the edge of two tiles saves exactly one ray, and the hash map cost as much as the rays it saved. Removed.

  2. 02

    Throwing memory at re-reads

    A full render once sampled each chunk about 2.5 times: 188k samples for 74.5k chunks. A cache four times larger helped by 6%, a Hilbert curve instead of Z-order by 2%. The fix was scheduling: isometric following the top-down frontier.

  3. 03

    Rendering on request

    The first version built missing isometric tiles on the HTTP thread under a per-world lock, and the first visitor to a new area paid for all of it. It moved to the queue, and later on-demand rendering was dropped altogether.

  4. 04

    Two samplers for two views

    Separate data paths read every chunk twice and resolved colours differently. One run representation made renders faster and made both views the same map.

Still to improve / 04

Walls in shadowTop-down
Shadows are decided per column from its top, so a wall in shadow still shows a lit side band.
Water depthTo the bed
Measured to the bed, not through the water: a ledge halfway down a lake reads as open water to the bottom.
Pick buffer size≈ the tiles
It costs about as much disk as the tiles themselves. A coarser buffer would keep most of the precision.
NightBlock light
Torches, lava and glowstone are already in the data, waiting for a night mode.

What is next

17 / 18

The map, in the game.

The largest item on the roadmap is not a rendering feature. It is a companion Fabric mod that brings the server’s map into the client: a full-screen world map and a minimap, drawn natively from tiles the server has already rendered.

Explored is not enough

Client-side map mods only show what the player has explored, chunk by chunk, as they walk past it. On a server with years of history that is a small fraction of the world, and it is out of date the moment someone else builds. The server already knows the whole world and keeps it current, so the client should not have to rediscover it.

Already in place

Tiles, ready to goCached across sessions
Small indexed PNGs with content-hash ETags. The first join after a long break costs a few conditional requests, not a re-download of the world.
Updates, pushedSame stream
The stream the browser already listens to keeps the in-game map current, including the ground underfoot.
Heights for freePick buffers
Coordinates on hover, correct waypoint placement and the block under the cursor in the minimap, without the client reading terrain itself.
Players and markersSame rules
Positions, regions, borders and shared waypoints arrive as JSON under the same permissions. A private world stays private in game too.
Both viewsFour rotations
Top-down is the natural minimap. The full-screen map can offer the isometric view with all four rotations, as the web does.

Still open

The open questions are on the client: tiles as textures that stay sharp at every GUI scale, when the minimap follows the server’s tiles and when it overlays what the client can see live, and a handshake that needs no link in chat, since a connected mod already knows who you are. As everywhere else, the server will do nothing for the mod that the browser could not also do.

Stack

18 / 18

One jar.

Everything below ships as a single plugin with an embedded web server. The tiles are plain files on disk, and nothing else has to be running.

Built with
LayerBuilt with
PluginJava 25 and Paper 26.2, with paperweight userdev for the NBT and region-file internals; fastutil primitive collections, Caffeine for the tile and chunk caches, Jackson
WebJavalin 7 on embedded Jetty and WebSockets; content-hash ETags even for bundled files, since every file in a jar shares one timestamp
FrontendLeaflet with CRS.Simple, plain ES modules and no build step; BroadcastChannel, DecompressionStream and the Popover API
ToolingPython and NumPy for the block data; JUnit for the geometry, sampling, projection and pick-buffer maths
IntegrationsWorldGuard regions, vanilla and ChunkyBorder borders, Chunky pre-generation, marker import from other web maps, and a public marker API

Taerra is running live on Corpium right now. If you play there, open the map and try to break it. A public release follows once it is ready.