Middle Earth: Giving life to the map (entities and encounters)

A map with roads and weather is still an empty map. This is the part where things start looking back at you.
Dragon perched on a peakA red-cloaked wraith with a bladeA woodman Rhovanion with an axeA white stag among standing stones

A handful of the 507 entities that live somewhere on the map (placeholder art)

The empty-world problem

By this point I had terrain, climate and routes. You could plan a three-day walk from Bree to the edge of the Old Forest and nothing would happen. No orcs, no wandering elves, no wolves at the treeline. Just you and some very well modelled mud.

A living world needs two things: a bestiary (who exists) and an encounter engine (who you actually meet, and when). Both had to respect the geography, or the whole illusion collapses. Meeting a Corsair of Umbar in the snowfields of Forochel would break the spell instantly.

The bestiary: 507 things that live somewhere

The entities table holds 507 entities: creatures, peoples, hazards, and some notable plants and sites, each with a UUID primary key. The breakdown by type says a lot about the world:

A few of the 29 typesCount
humans103
carnivores44
flying animals43
elves31
herbivores30
reptiles28
orcs18
trolls10
hobbits7
maiar / giants / demons3 each

The key field is probability_by_region, a JSONB array. Each entity carries a list of the regions it can appear in and how likely it is there, on a 1–8 scale (1 = you’d be unlucky to see it, 8 = it practically lives on the path). Entities also have an active pattern (day, nocturnal, or all-day) and a danger rating.

// entities.probability_by_region
[
  { "region": "Mirkwood Wilds", "probability": 7 },
  { "region": "Woodland Realm", "probability": 4 }
]

The encounter engine: from probabilities to a moment

The engine (backend/services/encounters.js) is written as pure, testable functions — no hidden state, seedable randomness — because “why did I meet a Balrog buying turnips” is a bug you want to be able to reproduce.

A day is split into three phases: morning, afternoon, night. Each phase rolls once with a 55% chance of something happening. If it hits, exactly one entity is drawn from the region’s pool. Here’s how that pool gets built.

1. Start with the region’s raw probabilities. For the region you’re standing in, gather every entity that lists it, with its 1–8 level.

2. Adjust the levels to the situation. The level moves up or down before it becomes a weight:

  • Time of day against activity (±1): a nocturnal creature is more likely at night and rarer at noon, and a diurnal one the other way around.
  • Road type (for intelligent beings only): you meet people where people go. A major road adds +3, a road +2, a trail 0, and going off-road −2. You won’t bump into a merchant caravan in a trackless bog.
  • Region population (for intelligent beings only): a density ratio from 0 to 1 maps to −2…+2. The Shire is crowded; the Ettenmoors are not.

Only the thinking types (elves, humans, orcs, dwarves, hobbits, trolls, ents) react to roads and population. A wolf does not care that it is a Royal Road. Every adjustment is clamped to the 1–8 range so nothing runs away.

3. Turn levels into weights. On a linear 1–8 scale an 8 is only 8× a 1, which is too flat: everything feels equally likely. So levels become weights on an exponential curve:

weight = 1.6 ^ adjustedLevel

Now an 8 is roughly 43× more likely than a 1. Common things feel common, and rare things feel like an event. The pool is then normalised to percentages and one entity is drawn with a weighted roll. There’s also a per-day exclusion list, so you don’t meet the same wandering ranger three times before lunch.

From “who” to “what happens”: the interaction resolver

Meeting a troll is not a story. What the troll does is. That is the job of interactionResolver.js, and it is where an encounter becomes a scene.

Once an entity is chosen, the resolver picks a form — one of ~18 interaction shapes, drawn from an interactions table and filtered by the entity’s danger level:

watches, stalks, sound_only, sign_only, glimpsed_far, drifts_closer, brief_exchange, aid_or_trade, confronts, hinders_passage, sudden_peril, attacks, presence_felt, reacts_withdraws, harvest_shelter, observed_activity, mistaken_for_object, scenery

A low-danger deer might get glimpsed_far, and a hunting warg might get stalks and then attacks. Selection is weighted, with two extra rules:

  • Anti-repetition: forms used recently get their weight multiplied by 0.3, so the chapter doesn’t turn into “watched, watched, watched again”.
  • Intensity control: high-intensity forms are suppressed from stacking within a chapter unless the danger genuinely warrants it. One near-death scene per day is plenty.

Dialogue, when it fits. If the chosen form is a talking one (brief_exchange, aid_or_trade, confronts), the resolver also pulls a conversation topic for that entity type and a stance for your character (from a character_voice table) — so a Ranger and a Dúnadan-hating Dunlending bring different attitudes to the same roadside meeting.

The (optional) resistance roll. For dangerous, roll-triggering encounters there’s a d20-style resistance rolldie + resistance/10 against a danger-scaled threshold — producing unscathed, wounded, badly wounded, or slain. (Feature-flagged off by default while I tune the numbers, because killing a character with a spreadsheet needs some care.)

The resolver hands the narrator a compact packet: form, prose hint, topic, stance, outcome. The skeleton of a scene, ready to be written.

Why do it this way?

Because every input is grounded in the map and reproducible. An encounter is the product of where you are (region), when it is (phase), what road you are on, how crowded the land is, and the creature’s own habits. It is not a random draw from a global list. Meet a spider in Mirkwood and it feels inevitable, and the same spider would never wander into the Shire, because its probability_by_region never mentions it.

And because the functions are pure and seedable, the same trip replays identically. Useful for testing, and for staying sane.

Lessons from populating a world

  • Probability belongs to place. Hanging the likelihoods off regions instead of a global table is what keeps the world coherent.
  • Exponential weights feel right, linear ones feel like noise. 1.6^level was the change that made everything click.
  • Separate who from what. The encounter engine picks the creature, the resolver picks the drama. Keeping them apart kept both simple.
  • Seed your randomness. A reproducible world is a debuggable world.

Next: I had a packet of cold facts — a wolf, at dusk, stalking, on a trail in the rain. Now I had to make it read like Tolkien wrote it. → Prompting and narrating like Tolkien from raw GIS information