Middle Earth: Creating the DEM System (peaks everywhere!)

How I turned a flat fantasy drawing into terrain you can actually climb, sweat over, and occasionally curse at.
Digital Elevation Model of Middle-earth built as a PostGIS raster
Elevation polygons and the DEM raster synthesized from marked peaks

The problem: a beautiful map that thinks the world is flat

Pete Fenlon’s map is gorgeous, but to a computer it is just ink. It shows mountains — little grey triangles, hatched ridges, the reassuring wrinkle of the Misty Mountains — but it doesn’t know about them. There is no “this pixel is 1,400 m up.” As far as PostGIS was concerned, Mordor and the Shire were equally, boringly flat.

That’s a problem, because half of what makes travel in Middle-earth interesting is that going over the Misty Mountains is a terrible idea and going around them is a long one. Without elevation, my router would happily send a hobbit straight over Caradhras in sandals.

So I needed a DEM — a Digital Elevation Model. A grid where every point has a height. The catch: real-world DEMs come from satellites and Middle-earth was, last I checked, not in orbit. I had to build one from scratch.

Step 1: peaks everywhere

The raw material was points. Lots of points. I went across the map and marked elevation, ending up with 10,925 elevation points stored in the elevation_points table — each one a POINT with an altitude_type (plain, hills, low/mid/high mountains) and a computed elevation_final in metres.

This is the “peaks everywhere!” phase, and it was exactly as tedious as it sounds. But points alone aren’t terrain — they’re a constellation. A traveller doesn’t walk on points; they walk on the slopes between them.

Step 2: altitude layers to give the points context

Alongside the points I drew altitude_layers — polygons for each altitude band (plains, hills, low mountains, mid mountains, high mountains). Each layer carries a priority field, which was crucial: mountains overlap hills overlap plains, and when a coordinate falls inside three polygons at once, priority decides who wins.

SELECT altitude_type
FROM altitude_layers
WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326))
ORDER BY priority DESC
LIMIT 1;

So the polygons say what kind of ground it is, and the points say how high it gets.

Step 3: baking it into a raster

Points + polygons then get synthesised into an actual raster — a continuous elevation grid — stored in the dem_elevation table using the PostGIS Raster extension:

CREATE EXTENSION IF NOT EXISTS postgis_raster;

CREATE TABLE dem_elevation (
    rid      SERIAL PRIMARY KEY,
    rast     RASTER,               -- elevation values in metres
    metadata JSONB DEFAULT '{}'::jsonb,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_dem_elevation_rast
    ON dem_elevation USING GIST(ST_ConvexHull(rast));

The metadata JSONB column keeps notes on how each raster was generated (resolution, bounds, algorithm version) — handy when you inevitably regenerate it for the fifth time because the mountains looked “wrong.”

Step 4: the one function everything else leans on

Here’s the payoff. All the messy generation collapses into a single, boring, wonderful SQL function:

CREATE OR REPLACE FUNCTION get_elevation_at_point(lon DOUBLE PRECISION, lat DOUBLE PRECISION)
RETURNS DOUBLE PRECISION AS $$
DECLARE elevation DOUBLE PRECISION;
BEGIN
    SELECT ST_Value(rast, ST_SetSRID(ST_MakePoint(lon, lat), 4326))
    INTO elevation
    FROM dem_elevation
    WHERE ST_Intersects(rast, ST_SetSRID(ST_MakePoint(lon, lat), 4326))
    LIMIT 1;
    RETURN COALESCE(elevation, 0);
END;
$$ LANGUAGE plpgsql STABLE;

Give it a longitude and latitude, get back a height in metres. That’s it. Every other elevation feature in the project is just this function called many times.

What the DEM actually powers

“How high am I standing?” — The /api/dem/elevation endpoint takes a lon/lat, returns the elevation and the terrain type from the altitude layers. One click on the map, one honest answer.

Elevation profiles (the fun one) — The /api/dem/profile endpoint takes a whole path and draws its cross-section. It interpolates points along the route every 100 metres, calls get_elevation_at_point for each one, and adds up elevation gain and elevation loss separately (climbing 300 m and descending 300 m is not a net-zero day for your legs):

totalDistance, minElevation, maxElevation, elevationGain, elevationLoss

That gain/loss figure is what makes a mountain feel like a mountain: it feeds straight into how exhausting the journey is.

Making routes honest — This is where “peaks everywhere” pays for itself. The routing engine multiplies travel speed by an altitude penalty, so the same distance costs wildly different amounts of effort:

TerrainWalking speed multiplierOn horseback
Plain×1.00×1.00
Hills×0.80×0.70
Low mountains×0.65×0.50
Mid mountains×0.50×0.30
High mountains×0.35×0.15

A horse in the high mountains moves at 15% of its open-field speed. Which is the game’s polite way of saying: take the pass, not the peak.

Lessons from building fake terrain

  • Points are data; slopes are meaning. The interesting information lives between the measurements, which is why interpolation and the raster mattered more than the raw dots.
  • priority saves lives. Overlapping polygons are inevitable on a hand-drawn map; a tie-breaker column is not optional.
  • One clean function beats ten clever queries. Hiding all the raster complexity behind get_elevation_at_point(lon, lat) meant the rest of the codebase never had to care how the sausage was made.

Next up: with the ground shaped, I needed to decide what the weather on top of it should be — and for that I went time-travelling to 1950. → How to create a Climate System from nothing