I gave NPCs a graph database. Now the whole village knows I stole an apple.

# gamedev# ai# database# python
I gave NPCs a graph database. Now the whole village knows I stole an apple.Yash Gurav

description: Hearsay is a memory-and-gossip engine for game characters built on FalkorDB. Villagers...

description: Hearsay is a memory-and-gossip engine for game characters built on FalkorDB. Villagers remember what they see, spread rumours along trust, exaggerate them, and can explain exactly who told whom.
cover_image:

I stole an apple in a video game. Only the merchant saw it.

By evening, the innkeeper had heard I robbed his stall. By night, the town guard had heard I robbed the market. Next morning, the guard, who never saw a thing, blocked the town gate.

So I asked her why. She told me: "I heard it from Bertha; Bertha heard it from Old Tomas; Old Tomas saw it first-hand."

That's Hearsay, the project I built for Graph Hacks: Context for AI Agents (WeMakeDevs ร— FalkorDB). It's a small 3D game, but the real thing is the engine underneath: game characters whose memories, beliefs and relationships live in a graph.

โ–ถ Play it: https://hearsay-six.vercel.app
๐Ÿ“ฆ Code: https://github.com/Yashgurav002/hearsay


The problem: NPCs are amnesiacs

Game characters forget everything. The shopkeeper greets you warmly five seconds after you robbed them. And when games do remember, it's usually a single number, a "reputation" or "honour" meter, that every character somehow knows instantly. Nobody heard it from anyone, nobody misremembers it, and nobody can tell you where it came from.

Real reputation doesn't work like that. It travels person to person, only between people who trust each other, it changes in the retelling, and it takes time to reach the next town.

Underneath, this is an agent-memory problem, not a game problem. Agents that share a world need:

  • memory that persists;
  • knowledge that moves between agents only through realistic channels;
  • a record of where each belief came from;
  • decisions you can trace.

That's a graph.

The idea: memories, beliefs and trust as a graph

Here's the whole model for one rumour chain:

(Tomas)-[:WITNESSED]->(Event: theft at the market)
(Tomas) -[:HOLDS {confidence: 1.00}]-> (Rumour sev 1: "took an apple")      โ”€โ”
(Pip)   -[:HOLDS {confidence: 0.45}]-> (Rumour sev 2: "stole a basket")      โ”‚ each one
(Bertha)-[:HOLDS {confidence: 0.75}]-> (Rumour sev 3: "robbed the stall")    โ”‚ -[:ORIGIN]->
(Ilsa)  -[:HOLDS {confidence: 0.51}]-> (Rumour sev 4: "robbed the market")   โ”‚ the real Event
                                                                            โ”€โ”˜
 Pip's version   -[:HEARD_FROM]-> Tomas's version
 Bertha's version -[:HEARD_FROM]-> Pip's version  AND  -[:HEARD_FROM]-> Tomas's version
 Ilsa's version  -[:HEARD_FROM]-> Bertha's version

 (Ilsa)-[:TRUSTS {weight: 0.75}]->(Bertha)-[:TRUSTS {weight: 0.8}]->(Tomas)
Enter fullscreen mode Exit fullscreen mode
  • An Event is what actually happened.
  • A Rumour is one villager's version of it. Every retelling creates a new Rumour node for the listener, with its own wording, severity and confidence.
  • HOLDS carries how sure they are. HEARD_FROM is the provenance chain. ORIGIN always points back to the truth, however distorted the story got.
  • TRUSTS is directed: "Ilsa trusts Bertha" means Bertha can tell Ilsa things.

Each player gets their own graph (hearsay_<playerId>), created with GRAPH.COPY from a read-only template village. That gives per-player memory isolation from the database itself, not from WHERE player = ... filters I might forget.

Three queries that do the real work

1. Who can gossip right now?

Every in-game hour, villagers move to their scheduled places. Then one pattern match finds every possible conversation:

MATCH (a:Villager)-[:AT]->(pl:Place)<-[:AT]-(b:Villager)
MATCH (b)-[t:TRUSTS]->(a)
RETURN a.id AS teller, b.id AS listener, t.weight AS trust, pl.id AS place
Enter fullscreen mode Exit fullscreen mode

Read it as: A and B are in the same place, and B trusts A, so A can tell B things. That one line is why gossip in Hearsay feels social. The guard never hears anything from the kid, because she doesn't trust the kid.

When A tells B a story, B gets their own version:

CREATE (b)-[:HOLDS {confidence: $conf, since: $tick, last_reinforced: $tick}]->
       (r:Rumour {id: $id, text: $text, severity: $severity, hops: $hops})
CREATE (r)-[:HEARD_FROM {tick: $tick, place: $place}]->(src)
CREATE (r)-[:ORIGIN]->(e)
Enter fullscreen mode Exit fullscreen mode

The maths is kept simple on purpose:

  • Confidence drops every hop: teller's confidence ร— trust ร— 0.9.
  • Severity can climb one step on a fixed ladder ("took an apple" โ†’ "stole a basket" โ†’ "robbed the stall" โ†’ "robbed the market" โ†’ "is part of a gang of thieves") with the teller's exaggeration chance. Pip, the village kid, exaggerates 80% of the time.
  • If B already knows the story from someone else, no new node is made. B gets a second HEARD_FROM arrow and corroboration raises their confidence: 1 โˆ’ (1 โˆ’ old) ร— (1 โˆ’ new). Two people telling you the same thing makes you more sure.

2. "Why do you think that?"

This is the query I'm proudest of, because it's the one that would be painful anywhere but a graph:

MATCH (v:Villager {id: $villager})-[h:HOLDS]->(r:Rumour)-[:ABOUT]->(:Player {id: $player})
WITH r, h ORDER BY h.confidence DESC LIMIT 1
MATCH path = (r)-[:HEARD_FROM*0..6]->(o:Rumour)-[:ORIGIN]->(e:Event)
WHERE o.hops = 0
WITH path, e ORDER BY length(path) ASC LIMIT 1
RETURN [n IN nodes(path) WHERE n:Rumour | n.id] AS rumour_ids, e.base_text AS what_happened
Enter fullscreen mode Exit fullscreen mode

Take the villager's strongest belief and follow HEARD_FROM arrows, zero to six of them, back to someone who saw it first-hand (hops = 0). The *0..6 covers "I saw it myself" for free. In SQL this is a recursive self-join; here it's one pattern, and it runs in about 2 ms.

The game turns the result into a comic strip, so you can watch the story get worse hop by hop:

The Why? strip: Tomas saw

3. Whose word spreads furthest?

CALL algo.pageRank('Villager', 'TRUSTS') YIELD node, score
RETURN node.name AS villager, score ORDER BY score DESC
Enter fullscreen mode Exit fullscreen mode

TRUSTS points from a listener to the person they believe, so PageRank flows toward the most-believed villagers. Bertha the innkeeper comes out on top. Add algo.WCC over strong trust for cliques ("the inn crowd", "the chapel pair"), algo.betweenness for brokers, and shortestPath for the fastest route to get a story to someone, and you have a spymaster that helps the player plan:

"Captain Ilsa trusts Bertha most (0.75). Bertha will be at the inn from 10:00: do a good deed where she can see it, or whisper it to Pip, who reaches her in 1 hop."

Graph algorithms as tools for an agent, and the player's way out of the mess.

The design choices that made it believable

  • A distortion ladder, not free-form AI exaggeration. The engine decides the severity (1โ€“5). The language model only rephrases it in the teller's voice ("Oh my stars, the traveller just snatched Tomas's stuff!"). The distortion is real, deterministic and testable, because it's in the graph.
  • The echo-chamber guard. A story is never told back to the person it came from. Otherwise two friends would keep "confirming" each other's rumour until they were 100% sure. In Cypher that's a variable-length pattern predicate: NOT (r)-[:HEARD_FROM*1..6]->(mine).
  • The sceptic. Mira the blacksmith ignores any rumour with fewer than two different tellers.
  • Forgetting. Every in-game midnight, beliefs nobody reinforced in 24 hours fade (confidence ร— 0.97).
  • Procedural memory. Behaviour lives in Rule nodes, like "guard: block the gate below opinion โˆ’0.3" or "guard: treat a gift as a bribe if she already distrusts you". The code evaluates the rules; the model never decides anything.
  • Two coordination channels. Gossip is informal. When the guard gets really angry, she posts a wanted notice at the gate, and anyone who walks past reads it. That's shared state between agents.

Why the AI doesn't hold the memory

It's tempting to throw everything into an LLM's context and call it memory. But a chat history:

  • grows until it doesn't fit;
  • vanishes between sessions;
  • can't be shared selectively between characters;
  • can't tell you where a belief came from.

The graph does all four.

So the villager agent loop is perceive (graph queries) โ†’ decide (code, from Rule nodes) โ†’ speak (AI, with only the facts the graph returned) โ†’ remember (graph write). I tested it over 50 scripted conversations with Gemma 3 4B running locally: 0 / 50 replies mentioned a villager outside the speaker's graph context. If the model is off or slow, template lines built from the same facts take over, and the game never breaks.

The numbers

Measured on a laptop with FalkorDB in Docker:

What Result
Belief + opinion reads, 500 player worlds alive in one FalkorDB ~1 ms p50, 2.3 ms p95, flat from 10 to 500 worlds
A full hour of village gossip (~20 queries) ~15 ms
"Why?" end to end 7.7 ms
Theft reaching the guard, over 50 seeds 50/50 times, median 9 hours, arriving at mean severity 2.5

What broke (and how I fixed it)

The honest part. These are the things that cost me real time:

  1. Duplicated rows from an OPTIONAL MATCH. My first echo-chamber guard was OPTIONAL MATCH echo = (r)-[:HEARD_FROM*1..6]->(mine) when mine could be null. FalkorDB expanded it to every path and duplicated the rows, while echo still read as null. The guard told Ilsa the same rumour twice in one hour. Moving the check into a WHERE pattern predicate fixed it.
  2. GRAPH.COPY under load. The load test died with could not fork: copies fork the server, and two at once fail. I serialised copies behind a lock with a short retry.
  3. ORDER BY length(path) after RETURN errored because path wasn't projected. Ordering in a WITH first fixed it.
  4. A thinking model in a game loop. Gemma 4 on AI Studio always emits a <thought> block before answering, sometimes runs out of tokens mid-thought, and took 14โ€“34 s per call. I strip the thought block and retry server errors. Retellings are written instantly with template text and reworded in the background, so ticks never wait on the model. The hosted demo uses templates; local play uses Gemma 3 4B on Ollama (~1โ€“4 s).
  5. A Promise that crashed React. Chrome's scrollIntoView() now returns a Promise. An arrow-body useEffect returned it, React tried to call it as a cleanup function, and the whole page unmounted. Effects get block bodies now.
  6. Vercel guessed wrong. It saw fastapi in requirements.txt, picked the FastAPI preset, and sent every request, even /, to Python. Pinning "framework": "vite" made the frontend static and kept Python for /api.

Beyond one village

The village is a demo. The reusable part is a small contract: events go in ("theft happened here, these characters are here"), and beliefs, opinions and explanations come out ("the guard believes you robbed the market, at 0.51 confidence, because Bertha told her").

Picture that in an open world:

  • A shop clerk sees a robbery and tells a cousin in a gang, and the gang prices you differently.
  • A bard carries "slew a griffin bare-handed" to the next town.
  • A sceptical alderman won't believe it until two people say so.
  • Tracing a rumour to its source becomes the detective mechanic.

A GTA-sized world would need gossip "level of detail" (full simulation only near the player), region graphs and batched ticks. But the model and the queries stay the same, and at ~1 ms per lookup, per-character graph reasoning is affordable.

Try it

  • โ–ถ Play: https://hearsay-six.vercel.app. Steal an apple, skip to the evening, then ask the guard "Why?". The ๐Ÿ”ญ Spyglass shows the live graph, PageRank and every Cypher query as it runs.
  • ๐Ÿ“ฆ Code, graph model and all 45 Cypher queries: https://github.com/Yashgurav002/hearsay
  • ๐Ÿ› ๏ธ Stack: FalkorDB, FastAPI, React + Three.js, Gemma.

GitHub logo Yashgurav002 / hearsay

NPCs that remember what you did, and gossip about it. A 3D game + NPC memory engine on a FalkorDB graph (Graph Hacks 2026).

Hearsay: Everyone's talking about you.

A memory engine that lets game characters remember what you did, gossip about it, and explain exactly who told whom, built on a FalkorDB graph. It's playable here as a small 3D village. The same engine is a baseline for NPC memory in any open-world or story game.

Hackathon: Graph Hacks: Context for AI Agents (WeMakeDevs ร— FalkorDB) Tracks: Track 02 โ€” Agent Memory and Coordination (primary) ยท Track 01 โ€” Agents That Act on Connected Data

Docs: How to play ยท Reading the graph (every chart in the UI explained) ยท Architecture ยท Measured results ยท Decisions

Third-person 3D: the inn crowd at dusk, mood rings at their feet

Why this is a baseline for NPC memory in any game

The village is a demo. The reusable part is the engine underneath: a graph that stores what every character saw, what they've heard, from whom, and how sureโ€ฆ

Built for Graph Hacks: Context for AI Agents (WeMakeDevs ร— FalkorDB). AI disclosure: I built this with an AI coding assistant (Claude Code) for much of the code and this write-up, and reviewed it myself; in-game voices use Gemma. All villagers, places and events are fictional.