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.

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:
| Terrain | Walking speed multiplier | On 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.
prioritysaves 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