One quadrant of Mercury, fully materialised at the detail our players can actually zoom to, would need 7.7 terabytes of memory. It runs in a browser tab in 2.2 mebibytes. This is how — including the three bugs that hid each other for weeks, and the fourth one that only appeared because I was writing this article.

Zero-G is a persistent space MMO that runs in a browser. Planetary surfaces are built on real mission data. At planetary scale that solves everything; the moment a player lands, it solves nothing.
| Source | Resolution |
|---|---|
| Mars — MGS MOLA global DEM | 463 m / pixel |
| Moon — LRO LOLA global DEM | 118 m / pixel |
| Moon — LOLA + Kaguya TC merge (60°N–60°S) | 59 m / pixel |
| Mercury | no altimetry we can use — a photograph |
Mercury is the worked example throughout this article, because it is the worst case. There is no usable height data at all: only a 1024 × 512 photographic map for the whole planet. Everything below kilometre scale has to be invented — plausibly, deterministically, and without ever contradicting what is actually known.
Mercury in production is a 153 × 153 grid of quadrants, each 32 × 16 km — 512 km². At maximum zoom the generator reaches a cell size of 0.016 m. Representing one quadrant completely at that pitch would mean:
1,966,085 × 983,042 Float32 samples ≈ 7.7 TB
Here is what a real maximum-zoom view of Mercury quadrant (41,44) actually costs, taken from production:
Terrain generation diagnostics (10 chunks) genMs: 1124.10 ms renderMs: 6.30 ms heightmapBytes: 2304000 cellSizeM: 0.02 m reliefFrequency: 0.000625 octaves: 12 roughness: 0.550 amplitude: 2000.0 m
2.20 MiB. About three and a half million to one. Everything below follows from that gap.
The server sends planet configuration, terrain-type parameters, a tile manifest, the planet map image, and the elevations of the selected quadrant and its eight neighbours. The browser generates height, slope, material, hillshade and passability arrays itself, in chunks.
Measured across Mercury (41,44) and its 3 × 3 neighbourhood, the per-quadrant payload is 2,542 bytes for nine quadrants — 282.4 bytes each. Moving around a planet is nearly free, because only parameters cross the wire.
The honest caveat, because someone will open devtools. That is per-quadrant traffic, not the cost of arriving. There is a one-time per-planet asset load: Mercury's guide image is 273,516 bytes, Mars's is 9,989,858, and a single terrain texture is 2,884,788. Loaded once, cached thereafter. The architecture claim is real; the door is not free.

At default zoom the whole quadrant is covered:
Terrain generation diagnostics (32 chunks) genMs: 1098.70 ms renderMs: 10.20 ms heightmapBytes: 1843200 cellSizeM: 33.33 m reliefFrequency: 0.000625 octaves: 5 roughness: 0.550 amplitude: 2000.0 m
32 chunks of 120 × 120 at 33.33 m per cell — 4,000 m per chunk, eight across and four down, the entire 32 × 16 km quadrant in 1.76 MiB.
At maximum zoom the subdivision level is 11, each chunk is 3.9 m wide sampled at 240 × 240, and only the chunks intersecting the viewport are built, plus a one-chunk margin. Ten chunks covers a few tens of metres of ground — a rounding error against 512 km². The 7.7 TB representation is never approached because it is never needed.
"Scan Local Map" does not fetch a heightmap. It builds a low-resolution base for the whole quadrant, generates high-resolution chunks only for the current view, reuses cached chunks on a repeat scan, discards queued fine work when the player navigates away while keeping the compact base, and holds detail in a bounded one-hour session cache.
| Generate | Render | Ratio | |
|---|---|---|---|
| Default zoom, whole quadrant, 5 octaves | 1,098.70 ms | 10.20 ms | 108× |
| Maximum zoom, viewport only, 12 octaves | 1,124.10 ms | 6.30 ms | 178× |
Rendering is 6–10 ms either way. Generation is the entire cost — and notice that it is almost identical at both extremes, despite maximum zoom running more than twice the octaves at a cell size two thousand times finer.
That flatness is the whole design working. Cost tracks the number of cells on screen, not the resolution being displayed, because resolution only ever exists where someone is looking. Zooming in does not make the world more expensive; it makes a smaller piece of it more detailed.
Roughly 1.1 seconds is still 1.1 seconds, and it still occupies the main thread. Moving generation into a worker is the obvious next step.
The obvious way to make procedural terrain reproducible is to seed each tile from its coordinates — hash(planetSeed, x, y). It is also the classic way to get a visible seam at every tile border, because neighbouring tiles then sample unrelated noise fields.
The height field here is one planet-wide noise field sampled in world coordinates:
globalSeed = terrainSeed fine relief = subSeed(globalSeed, 1)
Quadrants are a way of talking about a region, not a unit of generation. Two adjacent quadrants meet correctly because they were never generated separately — they are two windows onto the same continuous field. Only the low-frequency guide layer carries a per-quadrant seed, where discontinuities do not show.
There is no Math.random() in the generation path. Same configuration, same coordinates, same seed, same ground — for every player, on every device, forever. Nothing is stored.
Where a body lacks altimetry, the tempting fallback is the photographic map as an elevation guide: bright pixel, high ground. It looks convincing and it is wrong.
Photographic brightness is albedo. On Mercury a bright crater ray system read as a 3,044 metre mountain while its neighbouring quadrants spanned −266 m to 869 m. A splash of ejecta became an alp.
The consequence is worse than "fix the guide": low-relief terrain types cannot be tuned against a photographic guide at all. We spent a long time adjusting Sand and Plains against a signal that was noise. Mercury now runs a forced synthetic guide for exactly this reason — its photograph is used for variance detection, not to drive height.
For a long stretch, changing the noise parameters did nothing visible. That is a maddening class of bug, because the instinct is to change the numbers harder.
1. The elevation ramp had no discrimination. First it auto-normalised and banded, making a smooth field look stepped. Then we over-corrected to the planet's full range — −5,380 m to 4,480 m on Mercury — which put a 700-metre quadrant inside a single colour band. Either way the display was lying about what the generator produced.
2. The noise term carried a fifth of the weight. Every roughness and frequency sweep was adjusting a small fraction of the smallest of three height contributions, while the thing visible on screen was a different term entirely.
3. Terrain types with no special shape were silently attenuated about threefold, and no parameter could recover it.
The shipped mix now normalises both contributions under one shared amplitude budget:
mixedLocalSignal = specialShapeWeight × shapeRaw
+ (1 − specialShapeWeight) × reliefNorm
with specialShapeWeight per terrain type, 0.35 by default, forced to zero where a type has no special shape.
What resolved it was not a better guess. It was printing the min and max of every height term, in metres, on every render, and then sweeping one parameter at a time. Six rounds of diagnosis from screenshots produced three confidently wrong conclusions; the first round with numbers on screen produced the right one.
Those are the same diagnostics blocks quoted above — they exist because of this bug, and they are how every number in this article was obtained.
If you debug a generator by looking at its output, you are debugging the renderer and the generator simultaneously, and you cannot tell which one is lying to you.
To write this article I needed timing numbers, so I added three more read-only fields to that same diagnostics block: generation time, render time, and total heightmap bytes.
The first reading came back at 5,582 ms for 48 chunks. Which was strange, because at that zoom the viewport can only contain about ten.
It was generating chunks nobody could see. Fixing it took the same view from 48 chunks to 10, from 11,059,200 bytes to 2,304,000, and from 5,582 ms to 1,124 ms — a factor of five, on a code path that had been shipping for weeks and looked fine.
So the lesson of the previous section proved itself while I was writing about it. The bug was not subtle and it was not hard to fix. It was simply invisible until a number was printed next to it.
Octave count is derived, not configured. A noise_octaves column exists in our schema and is ignored:
reliefFrequency = noiseBaseFrequency / max(quadW, quadH) × 40 nyquistFrequency = 0.5 / cellSizeM octaves = clamp(ceil(log2(nyquist / reliefFrequency)), 1, 12)
On Mercury (41,44) that gives 0.000625 cycles/m, producing 5 octaves at the 33.33 m default cell and hitting the cap of 12 at maximum zoom — visible in both blocks above.
And the thing that would have saved us weeks: on a 32 km quadrant, base frequency 1.0 gives features around 1 km across — thirty per tile, which reads as gravel rather than landscape. "A few large landforms" is 0.1 to 0.15. We started at 1.0 because it is the obvious default.
Related: normalised fBm never reaches ±1 in practice, so delivered relief lands around 0.4–0.7 × configured amplitude depending on roughness. Set 300 m, measure 180 m, nothing is broken.
The tiling repeats and there are fewer terrain types than there should be. Fly around for an hour and the same textures come round again. Generation still runs on the main thread and still costs about a second.
The generator was the expensive part. Terrain variety is cheap from here — which is a pleasant thing to be able to say, and also exactly what someone would say who had run out of time. Both are true.
Land a ship on Mercury, zoom to maximum, scan again, and it sits on lava plains at its true size — on ground resolved to sixteen millimetres per cell, derived from a planet whose only survey data is a photograph 1024 pixels wide.

What is known sets the shape of the world. Everything finer is a plausible guess that is not allowed to argue with it.
Zero-G is a free persistent space MMO that runs in any browser, on desktop or phone — no download, no client, and playable as a guest with no email. HTML5, WebGL, Three.js and Node.js. The world has never reset since it opened in January 2026. space.zerog.live