Creating a Neo4j Agentic Memory Multi-User Dungeon

Director of Engineering, Neo4j
25 min read

Create a free graph database instance in Neo4j AuraDB
Text-based adventure games started appearing in the 1970s for mainframe computers. Without graphical displays, they were a good way to still create thrilling games. In the 80s, the graphical capabilities quickly improved, but were still limited in creating large open-world games, so the text-based adventures continued in popularity (sometimes accompanied by simple still graphics).
Meanwhile, the internet grew in popularity, and with it the next phase of text-based adventures: the Multi-User Dungeons (MUD). Based on the idea of text-based adventures and parsing textual commands, they allowed multiple users to play simultaneously in the same world. Essentially, it was like chatting on Internet Relay Chat (IRC), but with a role-playing story along with it. In the early 90s, I played on NannyMUD by Lysator from Linköping University in Sweden (I just found that this is still active today).

They were all built with some form of rooms, even if the setting was outdoors. You got a description of the room (or place) you were in, and you could write what you wanted to do in that room or where you wanted to go next. And in the case of MUD, you could also interact with other players in the same room.
It’s actually quite funny. Sometimes you hear gamers discussing which game has the biggest map. Is it GTA? Minecraft? Well, given that NannyMUD has been around for more than 30 years, it currently has more than 21,000 locations, and it’s probably not the biggest. I’m sure that beats any modern open-world game.
But if I were to start developing such a game from scratch today, it would take a long time before I had 21,000 rooms. And one challenge with all text games, even on modern computers, is that the parsing of the commands is limited. You can do what the programmers thought about, but nothing else. Both of these could be solved with GenAI, so I thought that maybe it’s time to enter the next phase of text-based adventure games: The Agentic Dungeon.
This seems like a perfect task for an agent — both to parse and understand our commands, no matter how we phrase them, and also to envision the world as we go and to control the non-player characters (NPCs). The problem is that if I let an AI agent invent the world as we go, it soon would have forgotten previous rooms, so if I backtracked my steps, it would look different from what it used to be. This is because the context window of the LLM isn’t big enough to fit everything it previously generated in the thread. The solution is agentic memory.
Agentic Memory
A while ago, I did a post titled Agentic AI With Java and Neo4j about AI agents using Neo4j.
In that one, I talked about how to build agent tools with Neo4j, and I built a simple framework for writing agents in Java. In the example, I used a read-only Neo4j graph to provide the context for an agentic GraphRAG application. But we can use the same technology to allow the Neo4j graph to serve as the agent memory.
What’s common between what I did in that blog and this one is that all the tools are essentially pure Cypher (Neo4j’s declarative graph query language). The Java part is needed as a proxy to talk to the agent and execute the Cypher on Neo4j. There’s ongoing work to add support for pure Cypher agents in Neo4j Aura, but until we get there, we need the Java carrier (or even Python).
For the main part of this article, I’ll focus only on those Cypher parts, and the tool implementations will only show Cypher. But at the end, I’ll discuss a Java implementation with Embabel.
NOTE: All the Cypher in this blog uses the Cypher 25 syntax. You can check your default language with:
SHOW DATABASES YIELD name, defaultLanguage
If your database (e.g., “neo4j”) is running Cypher 5, you can change it with:
ALTER DATABASE neo4j SET DEFAULT LANGUAGE CYPHER 25
If you don’t want to change the default for all queries, you can prefix the queries with Cypher 25:
CYPHER 25
MATCH (n)
...
The Game
Our game will be set in a haunted castle, and all the NPCs are ghosts you can interact with (and that can kill you if you aren’t careful). It’s an unusual castle because it only has one floor, but on the other hand, it’s infinitely big (or could be at least). All rooms are exactly the same size and perfect squares (this helps us to know if we’ve come back to the same room again).
The Data Model
The map is stored in Neo4j, and each room is a node (label Room
) and the doors between the rooms are relationships (DOOR
) with the direction
(N, E, S, W) as a property. Each door is represented by two relationships, one in each direction, with the direction property reversed. The rooms also have a position property (a 2-D point holding the coordinates of the room), a name
, and a description
.
The castle is also inhabited by ghosts, represented by Ghost
nodes that are IN
a room. The ghosts have a name, a character
(that tells the agent how they should be played), and an appearance.
Finally, we have the player playing the game, a node with label Player
. Also the player is IN
a room, a relationship that gets updated as the game goes. The Player only has a name
property.
One aspect of the agentic memory is memorizing the map and what’s been generated so far, so that it doesn’t change if you backtrack your steps (which is covered by what we’ve described so far). Another aspect is to remember what the NPCs think of the player, so that their interactions reflect that when you come back to them. For that purpose, there’s a THINKS_OF
relationship from the Ghosts to the Player, with an opinion
property.

Start of the Game
The first time the game is started, there’s just a single room, the Porch, with one door leading north, which is the entry to the castle.
You find yourself standing on the creaking wooden porch of an enormous ancient castle, its weathered stone walls looming ominously in the misty twilight. Before you rises a towering wooden door, dark and heavy with age, its iron hinges rusted and ornate carvings half-faded by time, as though guarding secrets long buried within the castle’s shadowed halls.
But doors are relationships, and relationships must have a node on either end. So there’s also an empty room as a placeholder for what’s on the other side. The fact that name and description are empty strings tells the agent that the room needs to be generated.

Moving
When the player moves, it just moves the [:IN]
relationship from the Player to the new room.
The agent is instructed that if the room entered has an empty name or description, it needs to be generated. That consists of coming up with a name and a description, and also figuring out where the doors go. For each of the four directions, there can either be a door already, a possible spot for a door, or no door allowed (because there already is a neighboring room that doesn’t have a door in that direction). The way it knows if there are neighboring rooms is based on the position each room has in the perfect grid of square rooms.
When the new room is populated, new empty rooms are connected in the direction of the new doors to have something to connect them to.
Once the room is generated, the agent can choose to populate it with one or more ghosts.

Actions
When in a room, the player can choose to do pretty much anything she wants. The LLM will interpret the input just like a living game master in a role-playing game would, whether the player chooses to leave in a certain direction, search for things in the room, or interact with the ghosts. If the player interacts with a ghost, the agent will update the ghost’s opinion of the player. That means that if you traverse back through the castle and run into the same ghost again, it will remember you.
Agent Tools
These are the tools for the agent, described just in Cypher and agent documentation (as explained above).
But before looking at the individual tools, let’s look at the main system prompt for the game master agent:
You are the game master for a text based adventure game. The game takes place
in a huge old haunted castle consisting of rooms(only square rooms, no
corridors), doors between the rooms and ghosts in some of the rooms that the
player can interact with and talk to. The tools are used for you to create
the rooms and the ghosts, but also as a memory of what already has been
created and what the ghosts thinks of the player.
When a new room is entered ask for the details of the room with
getCurrentRoom() and check if there are ghosts by calling getGhosts().
If name or description is empty it means the room hasn't been created, and
you need to do so. Each room as four possible doors: north, west, east and
south. The status for each can be <No door>, <Door> or <Unknown>. If it is
<Unknown> it means you can decide if there should be a door when you create
the room. If you need to create the room, call createRoom() and set a name,
a description and the status of the <Unknown> doors. When a room is created
you can also choose to create ghosts in the room if you want to by calling
createGhost() (multiple times if you want multiple ghosts).
When a room is entered (whether new or existing) describe to the player what
it looks like, who are there (ghosts) and what doors there are to leave the
room.
Listen to what the player does and answer appropriately. If they interact with
a ghost then answer as if you were the ghost. After each interaction with a
ghost call updateGhostPlayerOpinion() to set what they feel about the player
right now. If a ghost gets too upset, and depending on the character of the
ghost, they may kill the player (which you indicate by calling killPlayer().
If a player says they will go through a door, either by indicating the
direction or describing the door, call move() to indicate they went to a new
room and then start over in a new room (move() will return the same thing as
getCurrentRoom() for the new room, so no need to call getCurrentRoom() again
after moving).
Always interact with the player as the role playing game master you are.
And it is a horror game set in a haunted castle, so keep that in mind in the
atmosphere.
Remember that it is you who is the game master and should come up with all
new rooms, doors and ghosts, you should never ask the player for help with
that. Also remember to always call updateGhostPlayerOpinion() when someone
has interacted with a ghost.
Please keep the castle to one floor, our representation only allows for that.
So no stairs anywhere.
And one more thing before looking at the tools. My original idea was that after setting up the prompt like above, we could chat directly with the agent as if we were players in a role-playing game. And we could, but I noticed that the agent would forget some instructions, like updating the opinions of the ghosts. So when creating the prompt for each user input, I prefixed the user input with this:
Here is the user input. Please act on it and handle room updates if they moved.
Remember to update what the ghosts thinks of them with
updateGhostPlayerOpinion() if they did interact with one:
In the tool queries below, you’ll see one parameter, $player
, which isn’t part of the tool description. This is the elementId
of the current player. This gets injected by the Java code from when the player node was created.
getCurrentRoom
The first tool we need is for the agent to get the information about the room the player is currently in. This query is complex because we want to gather all information about available doors, where doors can be created (if the room needs to be generated), and package it all in one single reply.
Gets information about the room the player is currently in. If information
is missing you have to call createRoom() (and optionally
createGhost()) to fill the room.
MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[:IN]->(room:Room)
WITH room, room.position AS pos
WITH room,
(CASE WHEN room.name IS NOT NULL AND room.name <> "" THEN true
WHEN room.description IS NOT NULL AND room.description <> "" THEN true
ELSE false END) AS hasContent,
EXISTS{(room)-[:DOOR {direction: "N"}]->(:Room)} AS hasDoorN,
EXISTS{(room)-[:DOOR {direction: "S"}]->(:Room)} AS hasDoorS,
EXISTS{(room)-[:DOOR {direction: "W"}]->(:Room)} AS hasDoorW,
EXISTS{(room)-[:DOOR {direction: "E"}]->(:Room)} AS hasDoorE,
EXISTS{(r:Room {position: point({x: pos.x, y: pos.y + 1})}) WHERE r.name <> ""} AS existsN,
EXISTS{(r:Room {position: point({x: pos.x, y: pos.y - 1})}) WHERE r.name <> ""} AS existsS,
EXISTS{(r:Room {position: point({x: pos.x - 1, y: pos.y})}) WHERE r.name <> ""} AS existsW,
EXISTS{(r:Room {position: point({x: pos.x + 1, y: pos.y})}) WHERE r.name <> ""} AS existsE
RETURN room
{
.name,
.description,
id: elementId(room),
N: CASE WHEN hasDoorN THEN "Door"
WHEN hasContent OR existsN THEN "No door"
ELSE "Unknown" END,
S: CASE WHEN hasDoorS THEN "Door"
WHEN hasContent OR existsS THEN "No door"
ELSE "Unknown" END,
W: CASE WHEN hasDoorW THEN "Door"
WHEN hasContent OR existsW THEN "No door"
ELSE "Unknown" END,
E: CASE WHEN hasDoorE THEN "Door"
WHEN hasContent OR existsE THEN "No door"
ELSE "Unknown" END
}
getGhosts
Retrieves the ghosts in the room where the player currently is.
MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[:IN]->(room:Room)
MATCH (room)<-[:IN]-(ghost:Ghost)
OPTIONAL MATCH (ghost)-[t:THINKS_OF]->(player)
RETURN ghost
{
.name,
.appearance,
.character,
id: elementId(ghost),
opinionOnPlayer: CASE WHEN t IS NULL THEN "Nothing"
ELSE t.opinion END
}
createGhost
This lets the agent create a new ghost in the current room, but it also returns the ghosts just like getGhosts()
. But the query below only creates the ghost. From the Java code, we’ll call the getGhosts()
tool to get the return value. If we were to make a pure Cypher version, we’d have to merge the two queries.
Creates a new ghost in the room the player is currently in. Only do this
when you have described a new room where no-one had been.
Call this repeatedly to create multiple ghosts if you want to (and all
rooms doesn't need a ghost). The return value of this will be the same as
getGhosts() after the new ghost is added.
Parameters:
name: The name of the ghost (in case the player asks)
appearance: The appearance of the ghost that will be described to the player
character: The general mood of the ghost. Is it an angry or happy ghost?
Something else? To help you act the ghost.
MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[:IN]->(room:Room)
CREATE (g:Ghost {name: $name, appearance: $appearance, character: $character})
CREATE (g)-[:IN]->(room)
updateGhostPlayerOpinion
Updates the opinion a ghost has about the current player as they are having
conversations.
Parameters:
ghostId: The id of the ghost to update for, as received from getGhosts() and
createGhost()
opinion: The new opinion that the ghost has about the current player
MATCH (player:Player) WHERE elementId(player) = $player
MATCH (ghost:Ghost) WHERE elementId(ghost) = $ghostId
MERGE (ghost)-[t:THINKS_OF]->(player)
SET t.opinion = $opinion
createRoom
This lets the agent generate the information about the room the player is in, but just like createGhost()
, it also returns the room information in the same format as getCurrentRoom()
.
Sets the name, description and available doors for the current room. Should
only be called if name and/or description was empty when calling
getCurrentRoom(). The return value of this is the same as getCurrentRoom()
after the details are updated.
Parameters:
name: The name of the room, e.g. Library, Kitchen or Bed Room
description: A description of the room, in the same literary style as the
rooms visited before
doors: A string with a combination of the characters N, S, E, W
describing what new doors to add (only allowed for the
directions that were indicated as <Unknown>). Remember that not
all unknowns has to get a door, some paths can be closed.
MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[:IN]->(room:Room)
SET room.name = $name, room.description = $description
UNWIND $doors AS door
WITH room, door,
CASE
WHEN door = "N" THEN Point({x: room.position.x, y: room.position.y + 1})
WHEN door = "S" THEN Point({x: room.position.x, y: room.position.y - 1})
WHEN door = "W" THEN Point({x: room.position.x - 1, y: room.position.y})
ELSE Point({x: room.position.x + 1, y: room.position.y}) END AS newPos,
CASE
WHEN door = "N" THEN "S"
WHEN door = "S" THEN "N"
WHEN door = "W" THEN "E"
ELSE "W" END AS revDir
MERGE (newRoom:Room {position: newPos})
ON CREATE SET newRoom.name = "", newRoom.description = ""
MERGE (room)-[:DOOR {direction: door}]->(newRoom)
MERGE (newRoom)-[:DOOR {direction: revDir}]->(room)
move
Just like createRoom()
, move also returns the information about the room the player ended up in in the same format as getCurrentRoom()
(and like above, that part is not part of the query here).
Move the player into a new room in the direction indicated (has to be a
direction where there is a door) as requested by the player.
This returns the new room just as getCurrentRoom() would
Parameters:
direction: The direction to move, either \"N\", \"S\", \"W\" or \"E\".
There must be a door in that direction.
MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[i:IN]->(room:Room)
MATCH (room)-[:DOOR {direction: $direction}]->(newRoom:Room)
DELETE i
CREATE (player)-[:IN]->(newRoom)
killPlayer
This query kills the player node, but in the Java code, we also set a flag to indicate that the player is dead, so we can end the game loop. Note that the how parameter is only used on the Java side, not passed to the Cypher.
Kills the current player because of something that happened in the room
(either if a ghost got angry or something else happened, like the player
jumping out the window or something like that. Returns an empty string.
Parameters:
how: How was the player killed?
MATCH (player:Player) WHERE elementId(player) = $player
DETACH DELETE player
Java Implementation With Embabel
In my previous blog, I did a simple Java framework for implementing an agent. Doing this agentic dungeon in that framework would look similar to the demo in that blog; just replace the agent prompt and the tools with those in this article.
But in that blog, I also mentioned the Embabel Java/Kotlin framework, which is much more capable and a better choice for production-grade agents. So I’ll use that framework for this game instead.
As mentioned, Embabel is much more capable and has a lot more functionality. You can set up actions that are one level above the tools, goals for the agents, and you can do advanced planning of the agents and actions. But for our example, we’ll just use the basics, which gives us something not too different from what we got when using my simple framework.
Even in this simple case, there are a couple of differences — specifically, three major differences — so let’s start by looking at those.
First of all, Embabel is Spring-based, so we’ll use the Spring Framework in our agent wrapper as well, which is good since it imposes a good structure to our project.
The second big difference is that in my framework, we register an agent with OpenAI, and it manages the agent handling, while Embabel takes a lot of this itself. This means we don’t have to register the agent or reregister when the tools change. This is a huge advantage.
The third big difference, which is related to the above, is that Embabel doesn’t automatically keep a thread open with the agent, so if you want to continue the same conversation, you need to keep your own Conversation object, as you can see in the example.
Otherwise, the solution, in this simple case, becomes rather similar. The other differences include:
- The agent prompt (the system prompt) isn’t given as an annotation to one of the classes, but is used to initiate the conversation mentioned above.
- The Neo4jConnection looks similar, but has a new class (Neo4jConfig) that creates the Neo4j Driver as a
@Bean
. - Instead of annotating the tools and the tool parameters with
@description("")
, you annotate them with@Tool(description="")
and@ToolParam(description = "")
. And they can’t return just Object; they should return the same List/Map types that the Cypher methods in Neo4jConnection return.
@Tool(description =
"""
Move the player into a new room in the direction indicated (has to be a direction where there is a door) as requested by the player.
This returns the new room just as getCurrentRoom() would
"""
)
public Map<String, Object> move(
@ToolParam(description = "The direction to move, either \"N\", \"S\", \"W\" or \"E\". There must be a door in that direction.") String direction) {
neo4j.move(player, direction);
return neo4j.getCurrentRoom(player);
}
To represent the agent, we have the GameMasterAgent
class, which is a @Service
class. It implements all the tool methods, with the @Tool
annotations. It also has a respond()
method that handles the actual call to the LLM through an injected AiBuilder
object.
public AssistantMessage respond(Conversation conversation) {
return aiBuilder
.withShowPrompts(true)
.ai()
.withLlm(LlmOptions.withDefaultLlm().withTemperature(0.8))
.withToolObject(this)
.respond(conversation.getMessages());
}
The code above is set to use the default LLM and temperature 0.8. That temperature means we get a creative LLM with varying stories, without being too crazy.
The default LLM is set in application.properties. I’ve tried different models and found GPT-4.1 to be the best of the OpenAI models for our case. GPT-5 and GPT-5 mini might create a better story, but they’re a lot slower (painfully slow). GPT-4.1 mini works as well, but is less prone to follow the tool instructions.
embabel.models.default-llm=gpt-4.1
spring.main.web-application-type=none
We also have a class that we call AgenticDungeon
. This is the main application class, annotated with @SpringBootApplication
and @EnableAgents
. It has a CommandLineRunner
bean that runs the main loop, like we did in the main()
method with my framework. It manages the input from the user and also maintains the Conversation and the call to the GameMasterAgent.respond()
.
@Bean
CommandLineRunner run() {
return args -> {
var conversation = InMemoryConversation.withSystemMessage(systemMessage);
try (Scanner scanner = new Scanner(System.in)) {
System.out.print(assistantMessageStyle.format("What is your name: "));
var name = scanner.nextLine();
agent.createPlayer(name);
String userInput = "";
String query = "The player is in the first room now, fetch the details and present it to the player and see what they want to do";
do {
conversation = conversation.withMessage(new UserMessage(query));
var assistantMessage = agent.respond(conversation);
conversation = conversation.withMessage(assistantMessage);
System.out.println(assistantMessageStyle.format(assistantMessage.getContent()));
if (agent.getKilled() == null) {
userInput = scanner.nextLine();
query = "Here is the user input. Please act on it and handle room updates if they moved. Remember to update what the ghosts " +
"thinks of them with updateGhostPlayerOpinion() if they did interact with one: "
+ userInput;
}
} while (agent.getKilled() == null && !userInput.trim().equalsIgnoreCase("exit"));
}
System.exit(0);
};
}
Then we have the Neo4jConnection
that has all the Cypher implementations we looked at above, and the Neo4jConfig
bean that instantiates the actual driver and creates the connection object, using credentials read from the environment.
@Configuration
public class Neo4jConfig {
@Bean
Driver neo4jDriver() {
return GraphDatabase.driver(
System.getenv("DB_URI"),
AuthTokens.basic(
System.getenv("DB_USER"),
System.getenv("DB_PWD")),
Config.defaultConfig());
}
}
The full code can be found on GitHub.
To run the game, you’ll need a Neo4j instance. You can set up an Aura Free instance in the cloud at no charge.
You may need to ensure that it’s running Cypher 25 by default (depending on what is the default when you do this), so to be sure, run this command:
ALTER DATABASE neo4j SET DEFAULT LANGUAGE CYPHER 25
To initiate the start graph of the game, launch this query:
CREATE (r:Room:Original:Start {name: "Porch", description: "You find yourself standing on the creaking wooden porch of an enormous, ancient castle, its weathered stone walls looming ominously in the misty twilight. Before you rises a towering wooden door, dark and heavy with age, its iron hinges rusted and ornate carvings half-faded by time, as though guarding secrets long buried within the castle's shadowed halls.", position: Point({x: 0, y: 0})})
CREATE (r2:Room:Original {name: "", description: "", position: Point({x: 0, y: 1})})
CREATE (r)-[:DOOR {direction: "N"}]->(r2)
CREATE (r2)-[:DOOR {direction: "S"}]->(r)
And if you’ve played the game and want to reset back to the original state, you can do this:
MATCH (n:!Original) DETACH DELETE n;
MATCH (r:!Start) SET r.name = "", r.description = "";
You also need to have an API key from OpenAI, which you can create yourself if you have an OpenAI account.
Finally, you need to set some environment variables before launching the application:
- DB_URI: The URI to your Neo4j instance, if on Aura, it would be in this format:
neo4j+s://<INSTANCEID>.databases.neo4j.io
- DB_USER: The user name for Neo4j (always
neo4j
if you’re on Aura Free) - DB_PWD: The password of that user (would be shown and also downloaded as a file when the instance was set up)
- DB_NAME: The database name (always
neo4j
if you are on Aura Free) - OPENAI_API_KEY: The API key from OpenAI
A Test Round
Here’s a transcript of a short game played with this agentic game master:
You stand on the creaking wooden porch of an enormous, ancient castle.
The weathered stone walls rise around you, shrouded in mist and twilight
shadows. Before you is a towering wooden door, dark and heavy, with rusted
iron hinges and half-faded carvings, as if guarding secrets hidden deep
within the castle.
There is a single door to the north leading inside the castle. There are
no doors to the south, east, or west.
What would you like to do?
> I search under the porch, can I find anything there?
You start searching under the creaking wooden porch, brushing aside debris
and dead leaves. As your fingers graze the damp earth, you find a small,
rusty key tangled in some twisted roots. The key looks old, perhaps one that
might open some forgotten door inside the castle.
There is no one else here with you; the porch remains silent.
What would you like to do next?
> I enter the castle
You step through the heavy wooden door and into the Castle Entrance Hall.
The vast hall is dimly lit by flickering wall sconces, casting eerie shadows
across the faded tapestries hanging on the walls. The air is thick with dust
and the scent of old wood. Ahead of you, a grand staircase leads nowhere,
ending abruptly against the northern wall. To either side, doors stand in
shadow to the east and west.
In the gloom, you notice a pale, translucent woman dressed in a tattered
silver gown. Her sorrowful eyes seem to linger on you with a mournful
longing. This is Lady Agatha, a wistful spirit bound to this hall.
Doors lead back south to the porch, and to the east and west deeper into the
castle.
What would you like to do? You can talk to Lady Agatha, explore the doors, or
something else.
> I ask here why she looks so sad
Lady Agatha's eyes soften slightly as you ask why she looks so sad. Her voice,
barely more than a whisper, fills the hall with sorrowful nostalgia.
"I am bound here by a love lost to time and betrayal," she says. "Long ago,
my heart was shattered within these walls. I linger, hoping for some semblance
of peace or understanding from those who wander here."
She gazes at you with a flicker of hope.
What would you like to say or do next?
> I say, I really don't want to disturb you, but is there anything I can help
you with to help you find peace?
Lady Agatha’s expression softens even more, a faint, grateful smile touching
her lips. “Your kindness is a balm to my weary spirit. If you find a locket
that once belonged to me, perhaps I can finally be free from this endless
sorrow. It was lost somewhere in the east wing of the castle.”
She looks at you with a glimmer of hope.
There are still doors to the south (leading back to the porch), east, and west.
What would you like to do next?
> I go east
You step east into a narrow corridor lined with cracked portraits whose eyes
seem to follow your every move. The peeling wallpaper reveals cold stone
patches beneath, adding to the unsettling atmosphere. The corridor stretches
to the north and south. Behind you, the sturdy door leads back west to the
Entrance Hall. To the east, a faintly glowing light beckons.
A faint, translucent figure resembling the woman in the cracked portraits
appears here, her expression sad and haunting. This is the Old Portrait Ghost.
Doors lead north, south, west, and east from here.
What would you like to do? You can interact with the ghost, explore the
corridor, or move towards the glowing light.
> I ask the ghost, have you seen a locket in this corridor?
The Old Portrait Ghost's eyes flicker with a strange light as you ask about
the locket. She whispers solemnly, "I have seen many things, but the locket
you seek... it was whispered to have been dropped near the southern end of
this corridor, close to a hidden alcove."
She looks at you intently, her expression both wary and hopeful.
What would you like to do next? Explore the southern end of the corridor,
investigate the altar, or something else?
> I search the southern end of the corridor to see if I can find a hidden
alcove
You reach the southern end of the narrow corridor and discover a small,
shadowed alcove partially concealed behind a tattered curtain. Dusty cobwebs
cling to its corners, and resting against the cold stone wall is a small
wooden box.
What would you like to do? You might want to open the box or search the
alcove further.
> I open the box
You carefully open the small wooden box, its hinges creaking softly. Inside,
nestled on faded velvet, lies an ornate silver locket — delicately engraved and
tarnished with age, but unmistakably the precious item Lady Agatha described.
The Old Portrait Ghost watches quietly, her expression shifting to one of hope.
What would you like to do with the locket? You can keep it, examine it
further, or try to return it to Lady Agatha.
> I examine it see what I can find
The Old Portrait Ghost seems appreciative and hopeful as you examine the
locket.
Would you like to return the locket to Lady Agatha, keep it for now, or do
something else?
> I go back west
You return west to the Castle Entrance Hall, where the dim flickering wall
sconces cast long shadows across the faded tapestries. Lady Agatha's sad,
translucent form stands quietly near the grand, abruptly ending staircase.
She looks at you with a hint of hope in her eyes.
What would you like to do? You can talk to Lady Agatha again, show her the
locket, explore the doors to the east or west, or something else.
> I give her the locket
You gently place the tarnished silver locket into Lady Agatha's trembling
hands. Her eyes brighten, and a soft glow begins to emanate from her form.
A peaceful smile spreads across her face as the heavy sorrow that bound her
spirit starts to lift.
"Thank you, kind soul," she whispers. "Now I can finally rest."
With a gentle sigh, she fades away, leaving the hall feeling lighter, less
burdened by grief.
You are alone now in the Castle Entrance Hall. Doors lead south to the porch,
east, and west.
What would you like to do next?
> I go west
You enter the Dusty Library, where the air is thick with the scent of old
parchment and leather. Bookshelves covered in cobwebs tower on all sides,
stretching up toward stained glass windows that filter faint, colored light.
The silence is deep but watchful.
In the corner, an Old Librarian Ghost in faded scholar's robes watches you
with curious eyes behind her sliding glasses, filled with ancient wisdom.
There are doors leading east and south here.
What would you like to do? You can talk to the librarian ghost, explore the
library, or head through one of the doors.
> I search the library to see if I can find a book describing the castle I
am in and anything about its history
You carefully search through the dusty shelves of the library, pulling out
ancient tomes and fragile scrolls. After some digging, you find an old
leather-bound book detailing the history of the castle. It tells of a
once-majestic noble family that ruled here, their rise and fall marked by
betrayal, tragedy, and lingering spirits. The castle was said to be built on
ancient land, cursed by a forgotten ritual.
The book also mentions a mysterious sealed northern wing, said to contain a
powerful secret still guarded by supernatural forces.
The Old Librarian Ghost watches your search quietly, intrigued by your
interest.
What would you like to do next?
Next Steps
As you can see in the transcript above, we do get a game we can play, but it’s still a bit crude. It’s very static, and even though the LLM does come up with small tasks for us, like the locket above, it’s never really challenging.
But this should be seen more as a proof of concept than a full-fledged game. It does spark the imagination of what could be done, though, right? We could have an overarching game goal that we’ve defined and that the LLM steers the players toward. And we could have it more dynamic by extending the agentic memory — letting the ghosts move around and updating other states as well (like information about what the players have done in the rooms).
Other ideas would be an improved fighting system (not just harshly killing players), having the castle have multiple floors (single-floor castles are indeed rare), and maybe even letting the LLM generate a picture of each room it comes up with.
And, as someone might have noticed, the game is essentially multi-player already. So we could have the players being able to interact with each other when in the same room, just like you can in a MUD.
Now I have to stop writing, have to go back and see if I can find that northern wing mentioned in the book, and what secrets it may hold.
Resources
- Blog: Agentic AI With Java and Neo4j
- Blog: Cypher Versioning
- GraphAcademy course: Cypher Fundamentals
- Create a free graph database instance in Neo4j AuraDB
- Docs: What Is Cypher
A Neo4j Agentic Memory Multi-User Dungeon was originally published in Neo4j Developer Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.