RPG MUD Playing Agent: Part 2
I'm still experimenting with writing a MUD Agent. Last time, I got it working in a minimal way. It could move around the world and start fights with mobs (and then lose). It basically works like this:
LLM <---> agent.py <----> CircleMUD
The python script's main role is to mediate communication in both directions. CircleMUD speaks telnet, an asynchronmous bi-directional protocol. The LLM is Llama 3.1 8B using llama.cpp to speak JSON over HTTP. The agent script also holds a small amount of state, which gets encoded into dynamic prompts. Claude Code is used to iterate on all this glue.
I've now added several new features, as discussed below. But a big rewrite is underway. Turns out that it's been awhile since my youthful foray into MUDs. I had forgotten just how very async some pieces are (fighting, for example). So I'm sorta starting over with proper async handling.
That said, what's new?
Server-side fixes
I made two small quality of life fixes on the server. First, I added an instant, one-step character creation and login command. This allowed the agent to skip past several inputs and to avoid a handful of slightly different behaviors depending on how the player had exited the game previously. Second, I increased the number of mobs and their respawn rates. I figured this would increase the rate of random combat encounters and make it easier to gain XP. Since I'm running the MUD locally, it made sense to apply minor server tweaks to simplify the agent.
Commands and command validation
In order to send commands to the MUD, the LLM needs to know which commands are available. One common technique is to include a description and full JSON example of each command in the prompt. However, with a fairly small 4K context window. I didn't want to waste space. My initial idea was to include a few important commands with example usage, and then instruct the LLM to issue a "help" command for the rest. This worked, but not great. I shifted gears and encoded the entire list of commands, categorized by type, with just 2-3 JSON examples. The format for most commands is very similar, just <command> or <command> + <parameter>, so I figured a few examples would be enough here.
This worked better, but I went a step further and added agent-side validation as well. Using the exact rules for each parameter, the agent could reject invalid commands immediately, and then re-prompt the LLM if necessary, to seek clarification without hitting the MUD.
Like this, assuming the initial "go" command was invalid.
"go"
LLM ------------> Agent
|
|
ERROR
reprompt |
"go <where>" |
LLM <------------ Agent
|
|
|
| "go west"
LLM ------------> Agent
|
OK
| "go west"
Agent ------------> MUD
There is a trade-off here. Extensive command parsing and validation logic adds complexity to the agent. The agent is no longer just translating between telnet and http. There could be false positives, where the agent rejects a valid command due to incorrect logic. There could also be protocol drift, if the MUD adds a new command or adjusts existing parameters and the agent does not. Those risks aside, in this case, I think it's an improvement. LLM outputs are fuzzy, so formal agent-side validation is a win.
Automatic Exits
Normally the MUD includes possible exits in the prose description of the room. Something like To the north, the path continues through a field... The LLM can contend with this. But I found it helpful to explicitly enumerate all the exits. This can be done by having the agent run the exits command, behind the scenes, for each room. In addition to the room description, exits could be listed explicitly in the prompt, like [Exits: north, south, up], which I think made them easier to understand.
Adding a stats HUD
As a fun feature, I wanted to add a little display of the current player stats. Something like this:
[HP:20/20 Mv:84/84 Gold:0 Lvl:1]
Normally the MUD hides these stats, and they're only accessibly by running the score command. During the game, the MUD tends to use relative terms, like "you are badly hurt" rather than "you have 6/20 HP". I wanted to see the actual numbers, so I had the agent periodically send the "stats" command in the background, transparent to the player, and then format these in a little HUD line. The stats were also included in the LLM prompt, for combat reasoning.
Fighting and fleeing
Using stats to control the fighting behavior took a few tries to get something satisfactory. The prompt initially said something like this:
** Fighting **
These are your player stats: [HP:6/20 Mv:84/84 Gold:0 Lvl:1]
- If HP > 70%, safe to fight
- If HP < 50%, you must flee
- If HP < 70%, fighting is risky
This has some problems. The relatively weak Llama 8B LLM doesn't fully understand that "HP:6/20 -> 30% -> flee", so fighting behavior was pretty random. I spent a morning experimenting with different prompts and this was the best so far:
Your HP: Low
Available actions:
- FLEE: Run away to safety (when HP is Very Low or Low - you're in danger!)
- DEFEND: Block attacks and conserve energy (when HP is Medium - be cautious)
- FIGHT: Attack aggressively (when HP is High or Very High - you're strong!)What do you do?
Respond with one word: flee, defend, or fight.
Having the agent map specific numbers (6/20) to words ("Low") is helpful. Conceptually both prompts express the same idea: "rules to fight, flee or defend", but exactly how the prompt is organized and worded makes a big difference in how well the LLM adheres.
Automatic Mapping
I thought it would be neat to add a mapping feature. Getting something basic working was actually super easy. The original idea was to add a new command called "map", which would only exist on the client-side, and allow the LLM to list all known rooms on demand. Each time the player entered a room, its location on a (x,y) grid was stored on the agent. Later I also added an "unexplored" flag, to distinguish whether the player had already visited every exit from that room. This allowed the agent to prioritize exploration by preferring to visit rooms with unvisited exits. I quickly dropped the "map" command idea, but retained similar logic to automatically add a list of "Nearby Rooms" directly to the prompt.
Avoiding Loops
A big problem was the agent getting stuck in a loop, performing the same actions over and over. For example, it would talk to the same mob, or walk back and forth between 2-3 rooms, rather than exploring. I tried a few different fixes. I think that each one helped, but the problem is not yet 100% solved to my satisfaction.
First, I added the last 10 commands to the prompt, along with instructions to avoid repeating commands. This required a bit of nuance, since sometimes repeating the same command is desirable (ex: "north, north") to move north twice. The command history allowed the agent to see what had recently been tried. The biggest help was adding a "goal" and a "plan" to the prompt. The LLM was able to modify its goal and plan through function calling. Outside of the prompt, the LLM has no memory, so including the goal/plan was helpful to do things that require persistence over multiple steps.
In the example below, the goal was something like "fight monsters to gain experience". At the very least, the responses looked good!
{
"command": "move",
"parameters": {"direction": "north"},
"thought": "The creepy crawler has left north, I
should try to follow it and potentially gain
experience"
}
But adding goals/plans also caused a new problem. The agent would get stuck thinking about updating its own goal. Claude referred to this as "meta loops". Adding a circuit breaker was a partial solution to this problem. The agent would monitor the command history, and if the same command was executed too many times in a row, the prompt would change to force the LLM into choosing a random direction instead, hopefully breaking the cycle of repeated commands by introducing some randomness.
Other Observations
Parsing data from the text stream is surprisingly annoying to get right. As mentioned, telnet is a bi-directional async protocol. MUDs take advantage of this by sending fairly unstructured text, while only accepting (kinda) strict input commands. There is nothing specifically delimiting a "room name" from a "room description", except a newline character. There's no obvious difference between an NPC and a monster, except their names and the overall context. I may look into modifying the server so that it labels the outputs in some way, perhaps emitting XML or JSON to make parsing easy for the agent. I may also try letting the LLM identify entities or help with parsing. It feels like lots of workable solutions exist, but none is clearly correct or best.
The other annoyance is that it takes 3-6 seconds to get a response from the LLM, since it is running locally on my macbook. That's plenty fast for "normal" gameplay. But it's quite slow for rapid experimentation. I'm not buying a new computer or renting cloud GPUs for this, so the options are to accept the slowness, to use a different LLM, to mock the responses, or to let the agent directly handle more behavior without hitting the LLM. Still experimenting with options here.
Finally, logging is still king. I was able to automatically solve a lot of problems by capturing the issue in a log file, and then directing Claude to check out a specific timestamp. Even so, it seems like Claude does over-specialize the solution, especially when writing fixes after analyzing log files, sometimes resulting in a lot of nested if-blocks and other special case handling.
Further Ideas
I have a few ideas on where to go next.
Along with proper async, I want to introduce "hot reload", so the agent can be modified on-the-fly without exiting the game. I'm hoping to get Claude to modify the agent behavior while it is still running.
I also want to add more admin commands. These would be helpful for testing. Things like teleporting a player to a specific location, or giving them better stats or lots of gold. This would all need to happen server side, which I've just barely experimented with so far.
A final thing would be to let the LLM manage its own memories. Currently the goal, plan, command history, and explored rooms list are all injected into the prompt. This is handled automatically by the agent. I'm thinking that allowing the LLM to save arbitrary memories could be useful. For example, when the goal is to fight monsters and gain experience points, it might encounter a monster, and want to remember that location for later, perhaps on a scratchpad.
So that's it for now. I hope to follow up on this once I've got the async agent fully working.