<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Emberfall: Building an AI Agent Game]]></title><description><![CDATA[Emberfall: Building an AI Agent Game]]></description><link>https://emberfallai.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 09:44:03 GMT</lastBuildDate><atom:link href="https://emberfallai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[She Refused Because I Insulted Her Brother Two Sessions Ago: Building AI NPCs With Graph Memory]]></title><description><![CDATA[How I built a swarm of AI game masters that share one memory graph, and why the graph, not the LLM, ended up being the interesting part. Built for Graph…]]></description><link>https://emberfallai.hashnode.dev/she-refused-because-i-insulted-her-brother-two-sessions-ago-building-ai-npcs-with-graph-memory</link><guid isPermaLink="true">https://emberfallai.hashnode.dev/she-refused-because-i-insulted-her-brother-two-sessions-ago-building-ai-npcs-with-graph-memory</guid><category><![CDATA[falkordb, ai, agents, graphdatabase]]></category><dc:creator><![CDATA[Hemant Gaikwad]]></dc:creator><pubDate>Sat, 17 Oct 2026 12:48:29 GMT</pubDate><content:encoded><![CDATA[<hr />
<p><em>How I built a swarm of AI game masters that share one memory graph, and why the graph, not the LLM, ended up being the interesting part.</em></p>
<blockquote>
<p>Built for <strong>Graph Hacks: Context for AI Agents</strong> (WeMakeDevs × FalkorDB), Track 02: Agent Memory &amp; Coordination. 🎮 Live demo: (<a href="https://emberfall-d55v.onrender.com/">https://emberfall-d55v.onrender.com/</a>) · 💻 Code: <a href="https://github.com/HemantGaikwad7/emberfall">https://github.com/HemantGaikwad7/emberfall</a> · 🎬 Video: <a href="https://youtu.be/kyxHE9RDE-0?si=IZ3vXQ2UOuJQIFqb">https://youtu.be/kyxHE9RDE-0?si=IZ3vXQ2UOuJQIFqb</a></p>
</blockquote>
<hr />
<h2>The moment</h2>
<p>My bard, Kael, walks into a forge and asks Grimma Ironroot, the only smith in town who can work moonsteel, to forge a blade.</p>
<p>She refuses:</p>
<blockquote>
<p><em>"No blade for you, Kael. Apologise to Borin and bring a gift, then we'll talk."</em></p>
</blockquote>
<p>Grimma was never in the tavern where, <strong>two sessions earlier</strong>, Kael sang a mocking song about a dwarf called Borin. But Borin is her brother. Word travelled.</p>
<p>Then I click <strong>"Why?"</strong> and the app draws the exact path through the database:</p>
<pre><code class="language-plaintext">Kael ─PERFORMED→ [S1: mocked Borin in song] ─TARGETED→ Borin ─KIN_OF(brother)→ Grimma
</code></pre>
<p>This isn't the LLM explaining itself after the fact. It's a Cypher query over the facts that drove her decision. That moment is the whole project in one image: <strong>agents that remember, coordinate, and can show their work.</strong></p>
<img src="https://github.com/user-attachments/assets/b1b3be05-b9ec-4b80-b3bd-ac5aff7917c9" alt="image" style="display:block;margin:0 auto" />

<h2>The problem with LLM memory</h2>
<p>LLM-based characters usually "remember" one of two ways:</p>
<ol>
<li><p><strong>Stuff the transcript into the prompt.</strong> It works until the context fills up, and nothing connects what happened to <em>whom</em>.</p>
</li>
<li><p><strong>Vector search over past chats.</strong> Good at finding <em>similar text</em>, bad at answering <em>"who would care about this, and why?"</em></p>
</li>
</ol>
<p>Grimma's grudge isn't similar text. It's a <strong>relationship chain</strong>: Kael did something to Borin, Borin is Grimma's brother, and Grimma's clan has an oath about kin. That's a graph problem, so I put the world in a graph.</p>
<h2>The architecture: three agents, one graph</h2>
<p>Emberfall runs a tabletop RPG with three kinds of agent:</p>
<ul>
<li><p><strong>Narrator.</strong> Reads the scene and story from the graph, narrates, and decides who should handle the player's action.</p>
</li>
<li><p><strong>NPC agents.</strong> One per character. Each decides <strong>only from its own memories</strong> in the graph.</p>
</li>
<li><p><strong>World-keeper.</strong> Owns consistency: validates proposed changes against world rules, then commits them.</p>
</li>
</ul>
<p>The key design rule: <strong>handoffs carry intent, never state.</strong> When the Narrator hands off to Grimma, it passes only <em>"Kael asks you to forge a blade."</em> Grimma's agent then queries FalkorDB for what <em>she</em> knows. Every agent reads and writes the same graph, so there's no state to drift out of sync between agents.</p>
<pre><code class="language-plaintext">Player ─▶ Narrator ──talk_to_npc──▶ NPC agent ──▶ writes Decision
                   ──travel / propose_world_change──▶ World-keeper ──▶ writes Events, moves, transfers
                              all of them ◀──▶ FalkorDB
</code></pre>
<h2>The graph model</h2>
<pre><code class="language-plaintext">(:Player)-[:PERFORMED]-&gt;(:Event {sentiment})-[:TARGETED]-&gt;(:NPC)
(:NPC)-[:KIN_OF {relation}]-&gt;(:NPC)          (:NPC)-[:MEMBER_OF]-&gt;(:Faction)
(:NPC)-[:REMEMBERS {via, weight}]-&gt;(:Event)  (:NPC)-[:FEELS {score}]-&gt;(:Player)
(:Rule)-[:GOVERNS]-&gt;(:Faction|:NPC)
(:NPC)-[:MADE]-&gt;(:Decision)-[:BASED_ON]-&gt;(:Event)   (:Decision)-[:APPLIED]-&gt;(:Rule)
(:Event|:Message)-[:IN_SESSION]-&gt;(:Session)-[:NEXT]-&gt;(:Session)
</code></pre>
<p>This covers the three classic memory types without any separate stores:</p>
<table>
<thead>
<tr>
<th>Memory type</th>
<th>In the graph</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Episodic</strong> (what happened)</td>
<td><code>Event</code> nodes and <code>REMEMBERS</code> edges, which record <em>who</em> knows and <em>how</em></td>
</tr>
<tr>
<td><strong>Semantic</strong> (what it adds up to)</td>
<td><code>FEELS</code> attitude scores and LLM-written session summaries</td>
</tr>
<tr>
<td><strong>Procedural</strong> (how to behave)</td>
<td><code>Rule</code> nodes such as the Ironroot kin-oath, linked to the factions they govern</td>
</tr>
</tbody></table>
<p>Conversation history is <code>Message</code> nodes chained to sessions, so a page refresh, or a new session, rebuilds everything from the graph.</p>
<h2>Memory that spreads like gossip</h2>
<p>When the World-keeper commits an event, four small Cypher queries decide who learns about it:</p>
<pre><code class="language-cypher">// the target remembers first-hand
MATCH (e:Event {id:$eid})-[:TARGETED]-&gt;(t:NPC)
MERGE (t)-[r:REMEMBERS]-&gt;(e) ON CREATE SET r.via = 'firsthand', r.weight = 1.0;

// family hears about it
MATCH (e:Event {id:$eid})-[:TARGETED]-&gt;(:NPC)-[:KIN_OF]-(k:NPC)
MERGE (k)-[r:REMEMBERS]-&gt;(e) ON CREATE SET r.via = 'kin', r.weight = 0.9;
</code></pre>
<p>Witnesses at the location (0.6) and faction-mates (0.4) follow the same pattern. Then every NPC who remembers it updates how they feel:</p>
<pre><code class="language-cypher">MATCH (n:NPC)-[r:REMEMBERS]-&gt;(e:Event {id:$eid})&lt;-[:PERFORMED]-(p:Player)
MERGE (n)-[f:FEELS]-&gt;(p) ON CREATE SET f.score = 0.0
SET f.score = f.score + e.sentiment * r.weight
</code></pre>
<p>A −3 insult to Borin becomes −2.7 for Grimma: she's angry, and the graph can say exactly why.</p>
<h2>Decisions you can trace</h2>
<p>The NPC agent has a single tool, <code>decide</code>, which takes a verdict, dialogue, reasoning, and the <strong>IDs of the memories and rules</strong> that drove it. The code throws away any ID the NPC doesn't actually have, so a model that invents a grudge gets its evidence silently dropped.</p>
<p>The decision is saved as a node linked to its evidence. The "Why?" button runs:</p>
<pre><code class="language-cypher">MATCH (n:NPC)-[:MADE]-&gt;(d:Decision {id:$did})-[:TOWARD]-&gt;(p:Player)
MATCH (d)-[:BASED_ON]-&gt;(e:Event)
MATCH path = (p)-[:PERFORMED]-&gt;(e)-[:TARGETED]-&gt;(t)-[:KIN_OF|MEMBER_OF*0..2]-(n)
RETURN path ORDER BY length(path) ASC
</code></pre>
<p>That variable-length match <code>*0..2</code> is the whole trick. It finds whether the NPC was hurt directly (0 hops), through family (1 hop), or through their clan (2 hops), and returns the actual nodes and edges for the UI to highlight.</p>
<h2>Redemption also travels through the graph</h2>
<p>Here's the part I like most. Kael travels to the mine (the World-keeper finds the shortest road route with a path query), apologises to Borin and hands over a pouch of gold. The World-keeper checks that Kael <em>owns</em> the gold before transferring it, then records the apology.</p>
<p>The <strong>same</strong> <code>KIN_OF</code> <strong>edge</strong> that carried the insult to Grimma now carries the apology. Back at the forge, her memories hold both events, the kin-oath is satisfied, and she agrees to forge the blade. The "Why?" path for her new decision runs through the Session 3 apology.</p>
<p>No special-case code. Just memory flowing through relationships.</p>
<img src="https://github.com/user-attachments/assets/14cbcc00-069f-4d54-afc9-ba7684aa604c" alt="image" style="display:block;margin:0 auto" />

<h2>Keeping the world honest</h2>
<p>LLMs love to improvise, so the World-keeper enforces rules stored as graph nodes:</p>
<ul>
<li><p>You can only talk to NPCs at your location. "Grimma isn't here; she's at the Rusty Anvil."</p>
</li>
<li><p>Items have exactly one owner. You can't gift what you don't have.</p>
</li>
<li><p>Travel follows <code>CONNECTED_TO</code> roads.</p>
</li>
</ul>
<p>In one test run, the narrator tried to record <em>"Grimma forged the blade."</em> The World-keeper rejected it, because Grimma doesn't hold the Moonsteel Ore; her brother does. The graph caught a plot hole the LLM didn't.</p>
<h2>Multi-tenant by design</h2>
<p>Each campaign is its own FalkorDB graph (<code>dnd_&lt;campaign&gt;</code>), so memory never leaks between tables of players. Turns within one campaign are serialised to keep the story linear, while campaigns run in parallel. Inside a campaign, memory is per player: Grimma refuses Kael but happily deals with Lyra, who never insulted anyone.</p>
<h2>The stack (all free)</h2>
<ul>
<li><p><strong>FalkorDB</strong> (Docker locally, FalkorDB Cloud free tier when deployed) holds all the state.</p>
</li>
<li><p><strong>Groq</strong> runs <code>openai/gpt-oss-120b</code>, with automatic fallback to other free models when one hits its daily limit.</p>
</li>
<li><p><strong>FastAPI</strong> plus one HTML page using vis-network for the live graph.</p>
</li>
<li><p>An <strong>offline rules engine</strong> runs the same graph flow without an LLM, so the demo never breaks.</p>
</li>
</ul>
<h2>What I learned</h2>
<ol>
<li><p><strong>Put relationships in the database, not the prompt.</strong> The LLM is good at voice and judgement. The graph is good at <em>who knows what and why</em>. Splitting it that way made the agents simpler and more reliable.</p>
</li>
<li><p><strong>Handoffs should carry intent, not state.</strong> Once every agent reads the graph itself, coordination bugs mostly disappear.</p>
</li>
<li><p><strong>Make the model cite IDs, then validate them.</strong> Cheap, and it turns "explainable AI" from a claim into a query.</p>
</li>
<li><p><strong>Small FalkorDB lessons:</strong> <code>shortestPath</code> doesn't support undirected traversals yet, so I used an undirected variable-length match ordered by <code>length(path)</code>. When ordering by path length while also returning <code>nodes(path)</code>, project the length to an alias first.</p>
</li>
<li><p><strong>Free tiers have limits.</strong> I hit Groq's daily token cap during testing, so I added model fallback and an offline mode. Your demo should never depend on one quota.</p>
</li>
</ol>
<h2>Try it</h2>
<ul>
<li><p>🎮 Play: <a href="https://emberfall-d55v.onrender.com/">https://emberfall-d55v.onrender.com/</a>. Click <strong>Load demo</strong>, then use the suggestion buttons.</p>
</li>
<li><p>💻 Code and full Cypher: <a href="https://github.com/HemantGaikwad7/emberfall">https://github.com/HemantGaikwad7/emberfall</a></p>
</li>
<li><p>🎬 2-minute demo: <a href="https://youtu.be/kyxHE9RDE-0?si=IZ3vXQ2UOuJQIFqb">https://youtu.be/kyxHE9RDE-0?si=IZ3vXQ2UOuJQIFqb</a></p>
</li>
</ul>
<p>Insult someone's brother. See how long they remember.</p>
]]></content:encoded></item></channel></rss>