Middle Earth: Survival, endings, and turning the narrator into an Interactive Story Engine

The narrator could write one good chapter. It could not remember the one before it. This article is about the difference between those two things.
A generated chapter shown in the book view of the app
One day of travel, rendered as a chapter

First, the journey had to be able to go badly

By the end of the previous article the map had ground, weather, roads, creatures and a narrator. What it did not have was risk. You could walk from Bree to Minas Tirith through snow, marshes and wargs, and arrive in the same condition you left in. Nothing was at stake, so nothing that happened meant much.

So the traveller got a survival layer, with four numbers:

StatWhat moves it
energydistance, elevation gain, weather, and hits taken in encounters
hungerdays on the road and what the traveller is carrying
thirstthe same, and worse in dry regions
shadowthe darker regions, and the worse encounters

Energy and shadow are the two that can end a trip. Energy works the way it always did: one hit halves it, two hits are fatal, and full recovery takes five days of rest. Shadow is the interesting one, because it is not damage. It sits somewhere between corruption and dread, it rises slowly, and it changes how the traveller sees the road rather than whether they survive it.

The numbers never reach the prose

This is the part I would defend if someone argued with me about it. The model never sees a stat.

Every stat is passed through a band: a numeric threshold becomes a categorical label, and the label becomes a sentence. So energy: 34 becomes energyBand: worn, which becomes something like “he is worn down, and the miles are beginning to tell”, and that sentence is what goes into the prompt as context.

// threshold -> label -> sentence
const band = energyBand(character.energy);      // 'fresh' | 'tired' | 'worn' | 'failing'
const line = ENERGY_SENTENCE[band];             // a written phrase, not a number

Two reasons for this. The first is that a language model given the number 34 will write “his energy was at 34”, or worse, invent a scale to explain it. The second is that bands are the natural boundary between the mechanics and the world: the thresholds are game design, the sentences are writing, and keeping them in separate files means I can retune one without rewriting the other.

Where you choose to go matters as much as how far you walk, because the region and the road type decide which entities can appear at all. Two travellers on the same route, with the same character, can end up with different stories. That was the goal.

Endings, and a PDF

A journey ends in one of two ways: the traveller arrives, or the traveller dies somewhere on the road. Both are endings, and the last day gets its own closing instruction in the prompt, so an arrival reads like an arrival and a death does not read like a normal Tuesday.

And then the whole adventure can be exported as a PDF: every chapter in order, with the route it came from. This was not a technically interesting feature and I like it more than most of the technically interesting ones. It seemed wrong that a story generated day by day, that exists nowhere else and will never be generated the same way again, should disappear when you close the tab.

The ceiling I hit

Here is the honest limitation of everything described so far: every chapter is generated in isolation.

The only continuity is a short summary of the previous day, written by rules rather than by the model, plus a list of phrases from yesterday that are explicitly banned so the prose does not loop. That is enough to stop the obvious repetition. It is not memory.

What it cannot do:

  • An NPC met on day 2 has never met you on day 6. The world has no idea you spoke.
  • Nothing accumulates. A near-death on a mountain pass does not colour the descent on the other side.
  • There is no world state. If a bridge is described as broken, nothing remembers it was.
  • I have no way of knowing whether a chapter is good other than reading it and deciding I like it.

Fixing those four things is a different project from the one I had been building, and it is the one I am turning into my Master’s final project for the Master’s in AI Automation & Agentic Engineering.

The plan: a narration API that doesn’t know about Middle-earth

The narration currently lives inside the Node backend, next to the routing and the encounters. The plan is to pull it out into a separate Python + FastAPI service, and to make one design decision on day one: the core knows nothing about Middle-earth.

Not because I want to sell a multi-world engine. Because when I looked at what I already had, part of it was accidentally generic and part of it was hardcoded to Tolkien, and the split was not where I expected it to be.

Already generic, portable more or less as it is: the interactivity rule engine (requirements as a skills dict plus AND/OR conditions, outputs as state_change, condition_set, clue, goal_encounter, item_gain) mentions nothing about Middle-earth in its shape. It is keys and numbers. Same for the multi-provider LLM rotation, which is pure infrastructure. Same for the band engine, structurally: a threshold becomes a label, and only the stat names and the phrases are Tolkien.

Hardcoded to the world: the system prompt, which literally says “a storyteller in the tradition of J.R.R. Tolkien”. The condition sentences, written as fixed English prose instead of templates. And the world vocabulary, which is regions, biomes, road types and skill names.

So the shape is: mechanics in the core, content in a world pack.

app/
  core/                    # no strings from any world
    models.py              # Character, StatBlock, Memory, NarrativeEvent, Chapter
    bands.py               # threshold -> label -> template
    rules_engine.py        # resolves requirements / outputs
    prompt_builder.py      # assembles sections from a WorldPack
    llm/                   # providers, fallback, sampling
  worlds/
    middle_earth/
      system_prompt.md
      stat_bands.yaml      # stat names, thresholds, sentence templates
      skills.yaml
      lexicon.yaml
  api/
    routes/narration.py

The pieces that change most:

  • A generic Character. Instead of fixed columns (skill_tracking, skill_lore), it becomes skills: dict[str, int], conditions: dict[str, Any], resources: dict[str, float]. The world pack declares which keys exist and what their thresholds are. The core never knows a skill by name.
  • Sentences become templates. The condition phrases stop being Python dictionaries buried in the logic and become Jinja2 templates inside the world pack: "{{name}} is worn down, and the miles are beginning to tell".
  • Memory as a real entity. Memory{id, character_id, text, tags, created_at}, modelled from the start instead of bolted on later. This is the whole point of the exercise, so it should not be a field on something else.
  • WorldPack. One object loaded by world_id that provides the system prompt, the band vocabulary, the skill definitions and the anti-repetition rules. prompt_builder asks the pack for sections and never hardcodes text.

The contract

The input schema is where the abstraction has to actually hold up:

{
  "world_id": "middle_earth",
  "character": { "name": "...", "skills": {}, "conditions": {}, "resources": {} },
  "narrative_unit": {
    "id": "day-3",
    "sequence_number": 3,
    "is_final": false,
    "events": [{ "type": "npc_interaction", "payload": {} }],
    "context_facts": ["passed through Eregion", "storm at dusk"]
  },
  "memories": [{ "text": "...", "tags": [] }],
  "continuity": { "previous_summary": "...", "banned_phrases": [] }
}

The hinge is context_facts as a list of free strings instead of rigid regions and biomes fields. That one choice lets the Node backend keep sending Middle-earth data exactly as it does today, while the API never has to know what a biome is.

And the things I am deliberately not doing yet, because the fastest way to ruin this would be to over-engineer it before it works once:

  • No plugin system or marketplace of worlds. A Python module plus some YAML files loaded by config is enough.
  • The routing and encounter engines stay in Node. They resolve what happens to the traveller, and that is not this service’s job.
  • No schema translation layer. The only discipline required is not writing Tolkien strings inside core/, and that single rule is what makes the extraction cheap later.

Where this is going: decisions

The end goal, and the reason memory matters at all, is that the traveller should be able to decide something at the end of each day. Rest or push on. Take the pass or go around. Talk to the thing at the treeline or avoid it.

That is the Choose Your Own Adventure version of the project, and the interesting part is that most of the machinery already exists in the wrong place: the routing engine can already recalculate a journey, the rules engine can already gate an option behind a skill, and the encounter engine can already decide what a choice runs into. What is missing is the thing that remembers you made the choice.

If I let myself get carried away, the version I keep thinking about connects the kilometres the user actually walks in a day to the distance the character covers, with whatever they run into along the way. But that is a different article, and probably a different year.

What “finished” would mean

The part I am least able to hand-wave is evaluation. Right now I judge a chapter by reading it, and that does not scale past my own patience and my own taste. A real Interactive Story Engine needs some measurable answer to “is this good”, covering at least whether the chapter contradicts the world state, whether it contradicts previous chapters, whether it reuses phrasing, and whether it does anything with the memories it was given.

That is the actual work, and it is the reason this stopped being a side project and became a thesis.

Lessons so far

  • Bands before prose. Converting numbers into labels into sentences kept the model away from the mechanics, and kept the game design out of the writing.
  • Look for the seam before you abstract. I did not decide the core should be world-agnostic on principle. I read what I had and found that half of it already was.
  • The unglamorous feature was the right one. The PDF export took an afternoon and it is what makes a generated story feel like it belongs to someone.
  • A narrator is not a story. One good chapter at a time is a solved problem. Memory, consistency and consequence are the problem.

← Back to the project, or start from the beginning.