DEV Community

Matt
Matt

Posted on

How I turned real-world borders into 408 game regions of roughly equal value

I've been building Last Thaw, a free browser strategy game that plays out on a real map of Earth over 30 real days while an ice age creeps in from both poles. It's still in beta, and I'm looking for people to test it, so if that sounds like your kind of thing, come break it.

This post is about the very first problem I had to solve: the map. Not drawing it, but carving it up.

The problem with real maps

If you hand a strategy game a real political map, you get a terrible board. Russia is one tile the size of a continent, and Luxembourg is a tile you can't click on. Use states and provinces instead and it flips: there are thousands of them, and the US alone has 50 states.

What I wanted was around 400 regions, each worth roughly the same to the player, that still follow real borders so you can look at the map and say "that's me, I'm in Nouvelle-Aquitaine" or "I'm in Western Australia". Big countries just contain more regions.

Everything runs as a one-off Node script that writes a single world.json the game ships with.

Piece Version / value
Source data Natural Earth admin-1 (states and provinces), 10m
Node + tsx Node 22, tsx 4.20
Geo libraries d3-geo 3.1, topojson-server 3.0, topojson-client 3.1, topojson-simplify 3.0
Target 400 regions (accepted range 380 to 420)
Result 408 regions in 149 countries, 312 sea routes, 838 KB of JSON

Step 1: define "value"

Area alone is a bad measure. A square kilometre of Siberian tundra isn't worth a square kilometre of the Po valley, and the game's ice age makes the far north worth even less over time. So each province gets a value from three things: its area, a latitude factor and a desert factor.

function latitudeFactor(absLat: number): number {
  if (absLat < 50) return 1;     // most of the populated world
  if (absLat < 60) return 0.6;   // northern Europe, southern Canada
  if (absLat < 70) return 0.25;  // the ice reaches here early
  return 0.05;                   // Arctic: nearly worthless
}

const areaKm2 = d3.geoArea(feature) * EARTH_RADIUS_KM ** 2; // steradians -> km2
const [lon, lat] = d3.geoCentroid(feature);
const desert = inDesert(desertBoxes, lat, lon) ? 0.5 : 1;   // Sahara, Gobi, Atacama...
const value = areaKm2 * latitudeFactor(Math.abs(lat)) * desert;
Enter fullscreen mode Exit fullscreen mode

The deserts are just hand-drawn lat/lon boxes in a JSON config (18 of them, "Sahara west", "Atacama", "Australia interior" and so on). Crude, but it's a game, not a GIS project, and a box you can read and edit beats a raster lookup you can't.

Step 2: build a topology so borders are shared

This was the step that made everything else possible. GeoJSON stores every polygon on its own, so two neighbouring provinces each carry their own copy of the border between them. TopoJSON stores that border once, as an "arc", and both provinces point at it.

// Shared borders become shared arcs; quantizing snaps nearly-equal points together.
const topo = buildTopology({ p: provinces }, 1e5);
const ps = presimplify(topo);                 // adds a simplification weight to every point
const neighbours = topoNeighbors(ps.objects.p.geometries); // adjacency for free
Enter fullscreen mode Exit fullscreen mode

From that you get three things almost for free:

  • Adjacency. Two provinces are neighbours if they share an arc.
  • Shared border length. Sum the haversine length of the arcs two provinces share.
  • Coastlines. An arc owned by exactly one province is coast.
// An arc with a single owner has no province on the other side: it's coastline.
for (const p of provs) p.coastal = p.arcs.some((a) => arcOwners[a].length === 1);
Enter fullscreen mode Exit fullscreen mode

Step 3: clean up the edges of the world

Before merging anything, two cleanup passes:

  1. Tiny isolated islands go. Anything under 2,000 km² with no land neighbour is dropped. Nobody wants to defend a rock.
  2. Micro-states merge into a neighbour. A country worth less than 0.25% of the world's total value joins the neighbour it shares the longest border with, unless it's at least 25,000 km². That size floor is what keeps Switzerland, Ireland, Cuba and Sri Lanka as their own regions instead of vanishing into a neighbour.

Step 4: merge provinces inside each country

Now the main loop. Each surviving country gets a target number of regions in proportion to its share of total value, with a minimum of one:

const target = Math.max(1, Math.round((scalar * TARGET_REGIONS * country.value) / totalValue));
Enter fullscreen mode Exit fullscreen mode

Then, inside that country, it's a greedy merge: take the lowest-value unit, glue it onto its lowest-value neighbour, and repeat until the country is down to its target. Trimmed down, it looks like this:

while (true) {
  const low = items.filter((w) => !w.fixed).sort((x, y) => x.value - y.value)[0];
  if (!low) break;
  // Stop once we're at target, the smallest piece isn't too small, and it touches something.
  if (items.length <= target && low.value >= minRegionValue && !isIsolated(low, regionOf)) break;

  const host = smallestAdjacent(low) ?? nearestWithin(low, 1500 /* km */);
  if (host) absorb(host, low);              // members, value, area and centroid all add up
  else if (low.areaKm2 >= 20_000) low.fixed = true; // Alaska, Hawaii, Svalbard stand alone
  else orphans.push(low);                   // re-homed later by longest shared border
}
Enter fullscreen mode Exit fullscreen mode

Merging into the smallest neighbour, not the biggest, is what keeps things even. If you merge into the biggest you get one blob that eats the country.

The centroid trick is worth calling out. Averaging lat/lon breaks near the date line, so each unit carries an area-weighted 3D vector instead, and merging two units is just adding their vectors:

function toVec(lat: number, lon: number, weight: number): Vec3 {
  const cl = Math.cos(lat * DEG);
  return [weight * cl * Math.cos(lon * DEG), weight * cl * Math.sin(lon * DEG), weight * Math.sin(lat * DEG)];
}
// absorb(): host.vec = addVec(host.vec, other.vec); the centroid is fromVec(host.vec)
Enter fullscreen mode Exit fullscreen mode

Step 5: hit the count

Because every country rounds its target and gets at least one region, the first pass never lands on 400. So the whole merge re-runs with a scalar until the count falls inside 380 to 420:

let scalar = 1;
let pass = runPass(scalar);
for (let iter = 0; iter < 12; iter++) {
  const n = pass.works.length;
  if (n >= 380 && n <= 420) break;
  scalar *= 400 / n;        // overshot? shrink every target a little, and vice versa
  pass = runPass(scalar);
}
Enter fullscreen mode Exit fullscreen mode

It usually settles in two or three passes.

Step 6: names, terrain, resources, sea routes

A region made of one big province keeps that province's name. One made of three or more small departments or counties borrows the parent region's name from Natural Earth, which is how a pile of French departments becomes "Nouvelle-Aquitaine". If a region ends up holding an entire country, it just takes the country's name.

Terrain and resources are seeded randomness with real constraints: tundra above 62°, desert inside the desert boxes, fish only on coasts and rivers, horses only on plains and hills. The RNG is seeded per region id (rngFor("lastthaw-1", "fr-nouvelle-aquitaine:resources")), so regenerating the map doesn't reshuffle what's already there. Then 183 hand-written overrides fix the things randomness gets wrong, mostly terrain and rivers: Switzerland and the Tirol are mountains, not whatever the dice said.

Sea routes link each coastal region to its two nearest coastal regions within 2,500 km, plus 47 hand-picked crossings like the Bering Strait. A final sanity check refuses to write the file if any region is isolated, has a one-way link, or has an empty path.

Step 7: fit the SVG into a byte budget

The map renders as SVG in the browser, so every region needs a path, and the whole lot has to stay small. I gave the paths a 700 KB budget and simplified to fit it. The catch is that simplifying two neighbours separately leaves slivers and gaps between them. The fix is the same topology from step 2:

  • Shared borders all use one uniform simplification weight, so both sides of a border drop exactly the same points and stay watertight.
  • Coastlines of oversized regions get simplified harder, one arc at a time, until no single path is over 4.5 KB.

One bug I lost an evening to: simplification can leave a ring with almost no area, and d3-geo then decides the polygon contains a pole and fills the entire sphere. If you ever see one country paint the whole map, that's why. The generator now drops near-zero rings and fixes the winding order before drawing.

How even did it come out?

Honestly, roughly. Value is normalised so the average region is 1.0:

Value
10th percentile 0.34
Median 0.88
90th percentile 1.54
Max 7.04

The long tail comes from the "at least one region per country" rule and from huge single provinces you can't split without inventing borders. Western Australia is one province, so it's one region. The low end is small countries that survived the size floor, like Rwanda and Haiti at about 0.1.

The other side effect is that northern Europe comes out coarse: the latitude factor hits it, so Germany is a single region while the US has 31. It's on my list. That's the beauty of the whole thing being one script with a few constants at the top, though: change a factor, re-run, look at the preview page, repeat.

Wrapping up

If you're building anything on a real map, the topology step is the part I'd steal. Shared arcs give you adjacency, border lengths, coastlines and gap-free simplification, all from one data structure.

And if you want to see the result, the map is live at lastthaw.com. Last Thaw is still in beta and I'm looking for testers, so sign up, pick a spot on the map and watch the ice come for it. The how to play page covers the rest. Feel free to borrow any of this for your own map, and tell me what breaks.

Top comments (0)