MFB · Devlog

MFB-002

129,024 cells and one ImageData

Hello, the overworld of Mutarch weighs zero bytes on disk. The save stores a seed string, and the whole region map, every coastline, river, kingdom, and road, is regenerated from that string on every launch and never persisted. The grid is 448 by 288 cells, 448 × 288 = 129,024. This is the long post of the series: first how the world is generated, then how it is painted, in enough detail that you could reimplement the pipeline. The sealed-box rule from #1 applies throughout: the generator is pure simulation code in src/game/world/map/, the painter in src/ui/features/regionMap/ reads what it produces, and the arrow points one way.

From a string to a coastline

generateRegionMap in regionMap.ts is an orchestrator: each stage a pure function of the layers before it, each rolling its randomness from its own stream forked off the world seed, with labels like terrain, climate, rain, and sites (the forking is next week’s post; “a seeded generator” will do for now).

Elevation comes first because everything is downstream of height. The base landmass is domain-warped fBm: the sampling coordinates are displaced by two low-frequency noise fields before reading the height field, which bends blobby noise into curved coastlines and crescent valleys. A ridged multifractal adds mountain crests, gated by a mask field so ranges form discrete belts instead of uniform roughness. Then the coastline is rolled: each world decides which map edges are open sea (at least one real coast guaranteed) and sinks heights toward them, so one seed is a peninsula and the next a landlocked basin. Even sea level is rolled: the ocean share lands between 24 and 42 percent, and the waterline is read off the height distribution by quantile so the share is hit exactly.

Temperature is next: a latitude gradient (which pole is warm is rolled per world) with weight 0.72, minus an altitude lapse of 0.58 times normalized height, plus a low-frequency wobble. Mountains are cold, one map edge trends hot, the opposite bleak.

Then the ocean mask, computed exactly when first needed: sea is every below-waterline cell flood-connected to the map border. Interior below-waterline pockets stay land for now; they become lakes later.

Rainfall needs that mask, because rain in Mutarch is simulated, not painted. A prevailing wind is rolled from eight directions and cells are processed most-upwind first. Each parcel inherits humidity from its upwind neighbor, picks up 0.175 per cell over open water, drops 1 percent as base drizzle, and dumps hard when forced uphill (the orographic term is humidity times rise times 9). Behind a range the parcel arrives dry: real rain shadows, lush windward slopes and wastes leeward, causality later stages lean on instead of faking.

Six panels of the same world seed after each pipeline stage: raw elevation, ocean mask, rainfall, rivers and lakes, biomes, and the final painted map with realm washes

Water flows downhill, eventually

Hydrology gets the most space because it earns it: every land cell must provably drain to the sea, rivers must follow real accumulated flow, and lakes must appear exactly where terrain traps water.

The naive approaches do not survive contact with noise. Steepest descent on raw terrain dies in the first local minimum, and fBm is nothing but local minima. Treating every pit as a lake gives a puddle world, hundreds of one-cell ponds and rivers that give up after eleven cells. Iteratively raising every depression to its spill level is correct but converges slowly and still needs epsilon care to avoid dead-flat plateaus.

The pipeline uses priority-flood depression filling (Barnes et al. 2014, cited in the header of hydrology.ts). Seed a min-heap with the ocean and the map border, commit cells in ascending height order, and raise any uncommitted neighbor that sits lower than the committed front to the front’s height plus an epsilon of 1e-5, which turns filled pit floors into gentle slopes so flow direction stays defined. One pass over the grid, and a strictly descending path exists from every land cell to the sea.

On the filled surface, D8 flow routing picks each cell’s steepest-descent neighbor of the eight (diagonal drops divided by the square root of 2). Then flow accumulation: cells run high to low, each contributing a base 0.05 plus its own rainfall and passing the total downstream. The rainfall weighting is why climate runs first: wet uplands feed bigger rivers than deserts.

Rivers are then just a threshold: the top 3.5 percent of land cells by flux (the 0.965 quantile) carry a river, width class 2 at 3 times the threshold, class 3 at 9 times. Lakes are every cell the fill raised by more than 2e-4, plus the interior below-sea basins from earlier. Finally the main stems are traced source to mouth for naming, keeping the 12 biggest of at least 12 cells.

Hand-drawn 5 by 5 grid worked twice: the priority flood committing cells lowest-first and raising a pit floor to its spill plus epsilon, then D8 arrows with flux numbers accumulating into a river that dies into the sea

Moisture is rainfall plus fresh water: a breadth-first sweep from every river and lake cell adds 0.28 within 2 cells and 0.13 at distance 3 to 4, so river corridors read as green ribbons through dry country. Biomes then fall out of a Whittaker-style temperature × moisture matrix, five bands each way, after the early-outs: ocean and shallows, lakes (a frozen lake classifies as glacier), peaks above 0.8 of relief, crags above 0.6, glacier below 0.13 temperature, and low soaked warm ground rotting into marsh or mire. Eighteen biome classes total, from Drowned Deep to Starved Peaks.

The political layers ride on a movement-cost surface: each biome gets an entry cost (open ocean 60, near-impassable but deliberately not infinite; peaks 14; plains 1) and big rivers add a fording penalty of 3 per width class. Realm territories grow from their capitals by cost distance (multi-source Dijkstra), so borders fall along terrain that would actually stop an army: mountain walls, big rivers, open water. Then the colony is placed, capitals with a minimum-territory guarantee (a capital cramped into a mountain pocket is re-placed), kingdom sites by per-kind suitability in bands of cost distance from the colony, roads as least-cost paths between them, and finally the gazetteer names the ranges, forests, lakes, and rivers.

One design inversion is worth pulling out: rivers change sides depending on who asks. To a kingdom a river is an obstacle, the fording penalty bends borders and roads around it. To a marching squad it is a corridor: the travel cost surface in travelPath.ts clamps any land river cell to 0.25, exactly the same discount a road gets (RIVER_TRAVEL_COST = ROAD_TRAVEL_COST). A deliberate worldbuilding choice: squads raft the banks, so the same river is a wall to a border and a highway to a traveler. Watching marches hug the waterways sells the geography better than any tooltip could.

How fast? I generated nine worlds from fresh seeds in a Vitest node process on my desktop (Ryzen 7 7800X3D, a fast CPU, so a best case): median 1.24 s per world, spread 1.06 to 1.41 s, about 9.6 microseconds per cell. Cheap enough to regenerate on every launch, far too expensive to regenerate on every render, which is why the result is memoized per seed and treated as immutable.

Painting 129,024 pixels

The renderer, paintRegionMap in src/ui/features/regionMap/renderRegionMap.ts, writes one pixel per cell into a single ImageData on a 448 by 288 canvas, and CSS upscales it with image-rendering: pixelated. The entire terrain is one 516,096-byte RGBA buffer and one putImageData call.

Two alternatives died first. A Pixi sprite per cell is the obvious reach, the engine already in the project for the combat arena, with pan and zoom free from the scene graph. But 129,024 sprite objects is dead on arrival: tens of megabytes of transforms and bounds for cells that never move, all touched on every pan. Canvas 2D rects are the simple cousin, but a fillRect plus a fillStyle parse per cell is 129,024 draw calls per repaint. Writing bytes into an ImageData costs four array stores per cell and nothing else.

Paint order per cell is a discipline, each layer tuned against what is already under it:

  1. Biome base color from a muted palette.
  2. Shading: water mixes toward a deep [8, 12, 24] by depth, land gets a hillshade, northwest light over the elevation gradient, brightness clamped to 0.7 through 1.3, which is what makes ranges read as relief instead of gray noise.
  3. Rivers in [82, 158, 216], dilated into their cardinal neighbors so a stream is not a one-pixel hairline, with a dark bank of [14, 26, 44] pressed around the channel.
  4. Road dust in [196, 158, 102] with a dark earth under-stroke of [26, 20, 14]. Roads paint after rivers on purpose: where they cross, the dust covers the blue and the crossing reads as a bridge, no bridge art required.
  5. Realm territory wash, one color per clan (the player’s holdings are cyan, #46d8c8): a solid line at the border, an interior wash that fades with breadth-first distance from the border, and a near-black under-stroke of [8, 8, 10] on the wilds side.

The under-stroke trick shows up three times in that list, banks, road edges, borders: outline the colored line in near-black and it reads over any background, as pixel artists have always known.

The river, coast, and road booleans live in one Uint8Array bitmask in src/game/world/map/types.ts: CELL_FLAG.RIVER = 1, COAST = 2, ROAD = 4. An object per cell with three boolean fields, the natural design, costs north of 40 bytes per cell in V8, call it 5 MB for what is 129,024 bytes here. Three parallel Uint8Arrays, the respectable version, is 387,072 bytes and three cache misses per lookup instead of one. One byte per cell, 5 bits still free, which at the current rate of feature creep is about a year of runway.

The pin that stayed blurry

The site markers, the pixel-art emblems pinned where the kingdom sites sit, were blurry. Soft at most zoom levels, and they shimmered while panning.

I blamed the asset. The emblems render around 16 CSS pixels from a 64 pixel source, so obviously the downscale was smearing them; I re-exported crisper art, and it was still blurry. The time in the art tool was me optimizing the part I understood instead of the part that was wrong.

The actual problem is arithmetic. A marker is sized in CSS pixels (16 at base zoom, growing with the square root of zoom so dense clusters separate before icons collide). On a 125 percent display that is 20 device pixels, and 20 device pixels showing a 16, 32, or 64 pixel source is a fractional resample ratio whichever source you pick. At fractional ratios bilinear smears, and nearest-neighbor (image-rendering: pixelated) drops some source rows and doubles others, which is the shimmer. A bigger source PNG only changes which fractional ratio you resample at.

The fix is to never resample fractionally at all. The emblem is baked at 16, 32, and 64 pixels (exact 4:1 and 2:1 nearest downscales, generate-icon-sizes.mjs), the marker picks the bake nearest its designed on-screen size (thresholds at 23 and 45 device pixels), and the camera snaps: round the designed device-pixel size to a whole multiple of the bake and write the residual scale into a --rmap-icon-scale CSS variable. The displayed size is always an integer multiple of source pixels in device pixels, where nearest-neighbor is pixel-perfect at any subpixel offset. The marker ends up a few percent off its designed size, and nobody can tell, including me.

The same capital emblem twice at 10x zoom: 64 px art squeezed to 31 device pixels comes out smeared, the 32 px bake at exactly 32 device pixels comes out crisp

One closing caveat. Because the map is regenerated instead of stored, every constant in this post is load-bearing: nudge the river quantile off 0.965 and every river in every existing world moves, touch the warp amplitude and the continents follow. During development that is a feature, worlds are disposable and I tune freely. Before release it needs a regionMapVersion stamp on the save so old saves can route to a frozen legacy pipeline, and I am deferring that on purpose; the comment at the top of regionMap.ts says so in capital letters, so future me cannot claim he was not warned.

As always, let me know what you think.