Skip to content
engineeringaigame-design

How We Built NPC Relationships Without a Graph Database

8 min readAaron Lindahl
Share

So here's the problem

I've been building an AI Game Master for D&D for about a year now. The AI runs combat, voices NPCs, tracks spell slots, the whole thing. But there was one thing that kept bugging me.

NPCs had amnesia. You save a village girl from a collapsing mine in session 2. Session 5, she talks to you like you're a stranger. The AI generates her dialogue from scratch every time because there's no structured way to say "this NPC likes you now."

I went looking at how other projects handle this. Found some open-source multi-agent simulation engines that use hosted knowledge graphs to track entity relationships. Impressive stuff. Also $50+/month in external dependencies, added latency on every memory lookup, and $200+/month in background LLM calls to simulate NPC behavior between sessions. (Yeah this bad boi is maxing out fast yikes.)

I wanted the results without the bill. NPCs that track how they feel about you, know their relationships with other NPCs, and actually change their behavior based on what you've done. Zero new infrastructure.

Wait, I already had most of this

So the question became: do I actually need a graph database?

Our memory system already stores campaign data as flexible JSON objects in PostgreSQL. Each memory has an importance score and a vector embedding for semantic search. When a player says "I talk to Wren," the system surfaces Wren's memory through hybrid search (vector similarity combined with importance ranking).

The JSON content field accepts literally anything. We'd been storing flat descriptions. Name, role, personality. That's it. No feelings, no goals, no connections to other NPCs. (Fair question why I didn't do this from the start. I sure don't have a good answer.)

Turns out I didn't need a graph database. I needed richer JSON objects.

What I added: Disposition, Connections, Goals

Four new concepts on NPC memories. No schema migration. No new table. The data model already supported arbitrary structure, I just wasn't using it.

Disposition is a score from -100 to +100 representing how the NPC feels about the player party. Think of it like a reputation bar in a video game, except the player never sees it. The AI uses it to decide how the NPC acts:

RangeLabelWhat happens
76 to 100DevotedShares secrets, risks their life for you, free stuff
51 to 75FriendlyHelpful, gives info willingly, trusts you
26 to 50WarmCooperative but not sticking their neck out
-25 to 25NeutralTransactional. You want something? Make it worth my time.
-50 to -26UnfriendlyCharges more, "forgets" useful info, closes the door faster
-100 to -51HostileActively working against you. Might call the guards.

Connections are the interesting part. Each NPC knows about their relationships with other NPCs. When the halfling apothecary talks about the village girl, the AI knows they're family. When the guard captain's ally has been mistreated by the player, the captain knows about it. "My brother at the smithy mentioned you." That kind of thing. No graph database needed.

Goals drive behavior. An NPC whose goal is "protect the village" reacts to a threat very differently than one whose goal is "make a profit." (Not rocket science, but the AI actually uses them.)

The hard part: don't nuke the personality

Adventure modules seed NPCs with rich persona data. Name, role, personality, appearance, secret knowledge. When the AI later bumps a disposition score because the player did something heroic, we can't lose everything else in the process.

The solution was a merge-on-update pattern. When the AI updates an NPC, we load the existing record first, preserve the fields that came from the adventure module (personality, appearance, secrets), and only overwrite the fields the AI actually changed. Disposition goes up, personality stays the same. Connections get merged by NPC key so you don't end up with duplicates.

Simple concept. Annoying to get right. But it means the AI can update one field without accidentally deleting everything else. (The kind of bug you don't catch until a player says "why does Wren not have a face anymore.")

Compact context, not JSON blobs

Dumping raw JSON into the AI's context window wastes tokens and looks like garbage. We render NPC data into compact one-line dossiers before they hit the prompt. Disposition label sits right next to the NPC's name. [Hostile -60] is pretty hard to miss.

This cut our NPC token usage by about 60% compared to raw JSON while making the information easier for the AI to parse. The AI adjusts its tone without any extra prompt instructions. Put the right data in the right format and the model figures it out.

What I didn't build (on purpose)

No between-session simulation. Some projects run autonomous NPC agents between sessions. Sounds incredible. Costs a lot in background LLM calls for content most players won't read. I let the AI generate "what happened while you were away" on the fly when the session starts. Same feel, zero background compute.

No graph database. A proper graph DB would let me query "who are the player's allies?" efficiently. But semantic search already surfaces relevant NPCs based on what the player is talking about. If you mention allies, the high-disposition NPCs show up naturally.

No relationship UI. I thought about adding a panel with disposition bars. Then I remembered that in actual D&D, you figure out how NPCs feel about you from how they act, not from a meter. The whole system is invisible to players. They just notice that NPCs are more consistent.

This approach isn't perfect. The AI sometimes forgets to update disposition after a big moment. And disposition is per-party, not per-character, which falls apart in PvP scenarios. But for 95% of games? It works.

The numbers

28
NPCs with relationships
42
new tests
~300
lines of code
0
new dependencies

No new database tables. No new external services. No latency increase. The whole thing lives inside the memory system I already had.

Sometimes the best infrastructure is the stuff you already built and forgot was flexible.

Enjoyed this post?
Share

More from the blog