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

How I turned a flat drawing into terrain that has real height, and makes the traveller pay for it.
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 beautiful, but for a computer it is just ink. It shows mountains: grey triangles, hatched ridges, the Misty Mountains running down the middle of the map. But it doesn’t know about them. There is no “this pixel is 1,400 m up”. For PostGIS, Mordor and the Shire were equally flat.

That’s a problem, because half of what makes travel in Middle-earth interesting is that going over the Misty Mountains is a bad idea and going around them takes weeks. Without elevation, my router would 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 problem: real DEMs come from satellites, and Middle-earth is not in orbit. I had to build one by hand.

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 as tedious as it sounds. But points alone are not terrain. 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) — useful when you regenerate it for the fifth time because the mountains looked wrong.

Step 4: the one function everything else leans on

All of that generation work ends up hidden behind one very boring 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.

Elevation profiles — 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 then descending 300 m is not the same as walking on flat ground):

totalDistance, minElevation, maxElevation, elevationGain, elevationLoss

That gain and loss number is what makes a mountain feel like a mountain: it feeds directly into how tiring the journey is.

Making routes honest — This is where “peaks everywhere” pays off. The routing engine multiplies travel speed by an altitude penalty, so the same distance can cost very 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 speed on open ground. The lesson is simple: take the pass, not the peak.

Lessons from building fake terrain

  • Points are data, slopes are meaning. The useful information sits between the measurements, which is why the interpolation and the raster mattered more than the points themselves.
  • A tie-breaker column is not optional. Overlapping polygons are unavoidable on a hand-drawn map, and priority is what resolves them.
  • 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 know how the raster was built.

Next: with the ground shaped, I had to decide what the weather on top of it should be. For that I went back to 1950. → How to create a Climate System from nothing