62 KiB
NetHack LLM Bot — Design Document & Engineering Plan
Project: nethack-llm — An LLM-powered agent that plays NetHack locally via PTY
Language: Go
LLM Backend: Anthropic Claude API
Execution: Local NetHack process driven via pseudoterminal
Goal: Full ascension run
Primary Output: Structured game recordings for LLM-driven eval/critique
Status: Draft v2.0
1. Project Overview
1.1 What We're Building
A Go application that launches NetHack locally via a pseudoterminal (PTY), maintains a virtual terminal to parse the game screen, extracts structured game state, and uses Claude to make decisions — from basic movement and combat through inventory management, survival, and long-term ascension strategy.
The bot produces rich, structured game recordings as its primary artifact. These recordings capture every game state, every LLM decision (with full reasoning), and every outcome — forming a dataset that a separate LLM "critic" agent can analyze to identify mistakes, suggest strategy improvements, and systematically improve future runs.
1.1.1 The Improvement Loop
The system is designed around a continuous improvement cycle:
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐
│ Play │────▶│ Record │────▶│ Critique │────▶│ Update │
│ (Bot) │ │ (Log) │ │ (Eval LLM)│ │ (Config)│
└──────────┘ └───────────┘ └───────────┘ └──────────┘
▲ │
└──────────────────────────────────────────────────────┘
- Play: The bot plays NetHack, making LLM-driven decisions
- Record: Every turn is logged with full context — game state, prompt sent, LLM reasoning, action taken, outcome
- Critique: A separate LLM agent reviews the recording, identifies bad decisions, missed opportunities, and strategic errors
- Update: Critique findings feed back into prompt templates, knowledge base, and strategy rules for the next run
1.2 Why This Is Hard
NetHack is one of the hardest games in existence. An ascension run requires:
- Thousands of decisions across 50+ dungeon levels
- Resource management — food, HP, items, gold, spell power
- Risk assessment — knowing when to fight, flee, or use consumables
- Long-term planning — quest completion, artifact acquisition, elemental planes
- Obscure knowledge — hundreds of special interactions, prayer timing, sacrifice rules, genocide targets, polypiling, etc.
- Adaptation — every run is procedurally generated; no memorized routes
An LLM must handle all of this with a finite context window and no built-in game tree search.
1.3 Success Criteria
| Milestone | Definition |
|---|---|
| M0 — Launch & Record | Launch local NetHack via PTY, parse screen, produce a valid game recording |
| M1 — Explore | Navigate dungeon levels, open doors, use stairs, avoid walking into walls |
| M2 — Survive | Fight monsters, eat food, manage HP, don't starve or die to trivial threats |
| M3 — Critique Loop | Eval agent reviews recordings, produces actionable feedback, feedback improves next run |
| M4 — Progress | Reach Minetown, complete Sokoban, identify items, manage inventory |
| M5 — Quest | Reach quest, complete it, retrieve artifact |
| M6 — Endgame | Reach the Castle, obtain the Amulet, navigate elemental planes |
| M7 — Ascend | Offer the Amulet on the Astral Plane altar |
Realistically, M0–M3 is the v1 target, with M4 as a stretch goal. The critique loop (M3) is a force multiplier — each run improves the next.
2. Architecture
2.1 High-Level Diagram
┌───────────────────────────────────────────────────────────────┐
│ nethack-llm │
│ │
│ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │
│ │ PTY │──▶│ VT100 │──▶│ Game State │ │
│ │ Driver │ │ Terminal │ │ Parser │ │
│ │ │◀──│ Emulator │ │ │ │
│ └──────────┘ └───────────┘ └────────┬─────────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │
│ │ Action │◀──│ Decision │◀──│ LLM Client │ │
│ │ Executor│ │ Engine │ │ (Claude API) │ │
│ └──────────┘ └───────────┘ └──────────────────┘ │
│ ▲ │
│ │ │
│ ┌──────┴───────┐ │
│ │ Memory / │ │
│ │ History │ │
│ └──────────────┘ │
└───────────────────────┬───────────────────────────────────────┘
│ writes
▼
┌──────────────────┐
│ Game Recording │
│ (JSONL + TTYRec)│
└────────┬─────────┘
│ feeds
▼
┌──────────────────┐ ┌───────────────────┐
│ Eval / Critique │────▶│ Updated Prompts │
│ Agent (offline) │ │ & Knowledge Base │
└──────────────────┘ └───────────────────┘
│ ▲
▼ │
┌──────────────┐ ┌──────────────┐
│ Local │ │ Critique │
│ NetHack │ │ Reports │
└──────────────┘ └──────────────┘
2.2 Module Breakdown
| Module | Package | Responsibility |
|---|---|---|
| PTY Driver | pkg/pty |
Launch NetHack subprocess, manage pseudoterminal, raw I/O |
| VT100 Terminal | pkg/terminal |
ANSI/VT100 escape sequence processing, 80×24 char buffer with color |
| Game State Parser | pkg/gamestate |
Extract map, player stats, messages, inventory, prompts from terminal buffer |
| LLM Client | pkg/llm |
Claude API integration, prompt construction, response parsing |
| Decision Engine | pkg/brain |
Orchestrates the game loop, manages turn flow, handles multi-step actions |
| Memory | pkg/memory |
Rolling game history, run summary, knowledge base |
| Action Executor | pkg/action |
Translates high-level actions ("move north") to keystrokes, handles multi-key sequences |
| Recording | pkg/recording |
Full TTY recording, structured decision log (JSONL), game state snapshots |
| Eval / Critique | cmd/eval |
Offline tool: feeds recordings to a critic LLM, produces reports and improvement suggestions |
| Config | pkg/config |
Game settings, API keys, tuning parameters, .nethackrc generation |
3. Module Design
3.1 PTY Driver (pkg/pty)
Purpose: Launch NetHack as a local subprocess and communicate via pseudoterminal.
Running locally via PTY instead of telnet gives us significant advantages: zero network latency, no connection drops, no server rate limits, full control over the NetHack binary and configuration, deterministic seeding (if desired), and the ability to run games as fast as the LLM can respond.
Key responsibilities:
- Allocate a pseudoterminal (PTY) with
os/exec+creack/pty - Set terminal size to 80×24 via
TIOCSWINSZioctl - Launch the NetHack binary with appropriate environment (
TERM=xterm-256color,NETHACKOPTIONS, custom.nethackrc) - Provide a clean byte-stream interface (read/write) to the terminal emulator layer
- Detect process exit (game over / quit) via wait status
- Optionally tee raw PTY output to a ttyrec file for visual replay
Interface:
type PTYDriver interface {
Start(ctx context.Context) error // Launch nethack subprocess
Read(buf []byte) (int, error) // Read output bytes from PTY
Write(data []byte) (int, error) // Write input bytes to PTY
Resize(cols, rows int) error // SIGWINCH
Wait() (*os.ProcessState, error) // Wait for process exit
Close() error // Kill process, close PTY
}
NetHack launch configuration:
type GameConfig struct {
Binary string // Path to nethack binary, e.g. "/usr/bin/nethack"
NethackRC string // Path to .nethackrc file (we generate this)
PlaybackDir string // Where to store playground directory
Seed int64 // Random seed (0 = random). NH 3.7+ supports this.
Cols int // Terminal width (80)
Rows int // Terminal height (24)
}
.nethackrc generation:
We control the NetHack configuration to make parsing easier and more reliable. The bot generates a .nethackrc tailored for machine consumption:
# Generated by nethack-llm — optimized for bot parsing
OPTIONS=color
OPTIONS=showexp
OPTIONS=showscore
OPTIONS=time
OPTIONS=autopickup
OPTIONS=pickup_types:$
OPTIONS=!autopickup # We handle pickup decisions via LLM
OPTIONS=!rest_on_space # Space only for --More--
OPTIONS=hilite_pet # Distinguish pets
OPTIONS=lit_corridor # Show lit areas
OPTIONS=standout # Use reverse video for --More--
OPTIONS=DECgraphics # Consistent line-drawing characters
OPTIONS=!tombstone # Skip tombstone animation on death
OPTIONS=disclose:yi ya yv yg yc yo # Auto-yes all death disclosures
OPTIONS=suppress_alert:3.4.3
OPTIONS=msg_window:reversed
OPTIONS=msghistory:20
OPTIONS=statushilites:10
Key options for bot parsing: showexp and time give us XP and turn count on the status line. disclose options auto-reveal inventory/stats on death for post-mortem analysis. DECgraphics gives us consistent wall/door characters across terminals.
Advantages over telnet:
| Aspect | Telnet (NAO) | Local PTY |
|---|---|---|
| Latency | 50-200ms network RTT | <1ms |
| Stability | Connection drops | Always stable |
| Speed | Limited by server/network | As fast as LLM responds |
| Configuration | Limited to server options | Full control of .nethackrc |
| Reproducibility | Random seeds per server | Can set seed for replay |
| Cost per run | Same | Same (LLM cost dominates) |
| Setup | Account required | Just install nethack binary |
| Rate limits | Possible server policies | None |
Go libraries:
creack/pty— the standard Go library for PTY allocation. Mature, well-maintained.os/exec— stdlib, for launching the subprocessgolang.org/x/sys/unix— forTIOCSWINSZioctl if needed beyond whatcreack/ptyprovides
3.2 VT100 Terminal Emulator (pkg/terminal)
Purpose: Process the raw byte stream from the PTY into a 2D character grid with attributes.
This is the most technically complex low-level module. NetHack uses VT100/ANSI escape sequences extensively for cursor positioning, color, and screen management.
Terminal buffer structure:
type Cell struct {
Char rune
FG Color // foreground color (ANSI 0-15 or 256-color)
BG Color // background color
Bold bool
Attrs uint8 // underline, reverse, etc.
}
type Terminal struct {
Width, Height int // 80×24
Cells [][]Cell // [row][col]
CursorRow int
CursorCol int
// scrollback, alternate screen buffer, etc.
}
Escape sequences we must handle:
| Sequence | Meaning | Priority |
|---|---|---|
ESC[H or ESC[;H |
Cursor home | Critical |
ESC[{r};{c}H |
Cursor position | Critical |
ESC[{n}A/B/C/D |
Cursor up/down/forward/back | Critical |
ESC[J / ESC[2J |
Clear screen / clear all | Critical |
ESC[K |
Clear to end of line | Critical |
ESC[{n}m |
SGR (color/attribute) | Critical |
ESC[?1049h/l |
Alternate screen buffer | Medium |
ESC[?25h/l |
Show/hide cursor | Low |
\r, \n, \b, \t |
Carriage return, newline, backspace, tab | Critical |
ESC[{n};{m}r |
Set scrolling region | Medium |
ESC[S / ESC[T |
Scroll up/down | Medium |
SGR (color) handling:
NetHack uses colors to distinguish monster types, item types, and dungeon features. The color of a character on the map often carries critical information (e.g., a red d is a hell hound, a brown d is a jackal).
ESC[0m — Reset
ESC[1m — Bold (often = bright color)
ESC[7m — Reverse video
ESC[30-37m — Foreground color
ESC[40-47m — Background color
ESC[90-97m — Bright foreground
Design considerations:
- Process bytes incrementally as they arrive from the PTY
- Use a state machine for escape sequence parsing (ground, escape, CSI parameter, CSI intermediate, etc.)
- Expose
ProcessByte(b byte)andGetScreen() [][]Cellinterfaces - Consider using an existing Go VT100 library like
danielgatis/go-vteas a starting point, but we likely need custom implementation for the game state extraction hooks
Go libraries to evaluate:
danielgatis/go-vte— VT parser in Gocreack/pty— PTY handling (used by the PTY driver layer)- Custom implementation — most likely path since we need tight integration with game state parsing
3.3 Game State Parser (pkg/gamestate)
Purpose: Transform the raw terminal grid into structured game data.
NetHack's screen layout is well-defined:
Row 0: Message line (game messages, prompts)
Rows 1-21: Map area (the dungeon)
Row 22: Status line 1 (name, stats, alignment)
Row 23: Status line 2 (HP, power, AC, level, gold, etc.)
Core game state structure:
type GameState struct {
// Map
Map [21][80]MapCell // rows 1-21
PlayerPos Position // location of '@' on the map
// Messages
TopMessage string // row 0 text
MorePrompt bool // "--More--" detected
// Status bars
Stats PlayerStats
// Derived state
VisibleMonsters []Monster
NearbyItems []Item
DungeonFeatures []Feature // stairs, doors, altars, fountains
// Prompt detection
PromptType PromptType // None, Direction, YesNo, Inventory, Menu, etc.
PromptText string
// Screen mode
ScreenMode ScreenMode // Normal, Inventory, Menu, TextDisplay
}
type PlayerStats struct {
Name string
Race string
Role string
Alignment string
STR string // can be "18/07" format
DEX, CON, INT, WIS, CHA int
HP, HPMax int
PW, PWMax int
AC int
XPLevel int
XP int
Gold int
Dungeon string // "Dungeons of Doom", "Mines", "Sokoban", etc.
DLevel int
Turns int
Hunger string // "Satiated", "", "Hungry", "Weak", "Fainting"
Encumbrance string // "", "Burdened", "Stressed", etc.
Conditions []string // "Conf", "Blind", "Stun", "Hallu", etc.
}
type MapCell struct {
Char rune
FG Color
BG Color
Type CellType // Wall, Floor, Door, Corridor, Stairs, Monster, Item, etc.
Walked bool // have we stepped here before?
}
Parsing challenges:
-
"--More--" detection: When there are multiple messages, NetHack shows "--More--" and waits for spacebar. The bot must detect this and send space before trying to act.
-
Prompt detection: Many game actions trigger prompts:
"In what direction?"— direction prompt"What do you want to eat? [a-zA-Z?*]"— inventory selection"Really attack the peaceful shopkeeper? [yn]"— yes/no- Menu screens (inventory, identify, enhance, etc.)
-
Overlay screens: Inventory, spell lists, and help screens overlay the map. We need to detect when we're in a menu vs the normal map view.
-
Symbol identification: NetHack uses the same character for different things depending on color. A complete mapping of character + color → game entity is needed. For example:
d(brown) = jackal,d(red) = hell hound pup,d(yellow) = dog.(gray) = floor,.(cyan) = ice,.(green) = swamp
-
Status line parsing: The bottom two lines have a specific format but can vary by configuration. NAO uses the default format. Regex-based parsing of HP, AC, stats, conditions, etc.
3.4 LLM Client (pkg/llm)
Purpose: Interface with Claude API, construct prompts, parse responses.
API integration:
type LLMClient struct {
apiKey string
model string // "claude-sonnet-4-20250514" for speed, opus for harder decisions
httpClient *http.Client
}
Prompt structure:
The prompt is the soul of this project. It needs to convey:
- System prompt: NetHack rules, symbol reference, strategy guidelines
- Current game state: Map, stats, messages, nearby entities
- Recent history: Last N turns of actions and outcomes
- Run context: What we've accomplished, current goals, inventory summary
- Action request: What should we do next?
System prompt design (abbreviated):
You are an expert NetHack player controlling a character via single-keystroke
commands. You are playing on nethack.alt.org.
GAME KNOWLEDGE:
- You are the '@' symbol on the map
- Movement: y/k/u/h/l/b/j/n (vi-keys: upleft/up/upright/left/right/downleft/down/downright)
- Common commands: s (search), . (wait), i (inventory), e (eat), q (quaff), r (read), z (zap), w (wield), W (wear), T (takeoff), d (drop), , (pickup), < (upstairs), > (downstairs), # (extended command), P (puton), R (remove), a (apply)
- Pray with #pray (emergency heal when below 1/7 HP, max once per ~700 turns)
MAP SYMBOLS:
- . floor # corridor | and - walls + door < upstairs > downstairs
- @ you d dog/canine : (eye/sphere) etc.
- Colors distinguish monster types (red d = dangerous hell hound, brown d = jackal)
CURRENT PRIORITIES:
1. Don't die (manage HP, avoid dangerous fights)
2. Don't starve (eat corpses and food rations)
3. Explore and descend
4. Collect useful items
...
Respond with EXACTLY ONE of these formats:
ACTION: <keystroke>
SEQUENCE: <keystroke1> <keystroke2> ...
THINK: <reasoning>
ACTION: <keystroke>
QUIT: <explanation of why you are stuck and cannot make progress>
Response parsing:
The LLM response must be parsed into actionable keystrokes. The response format is constrained to make parsing reliable. We extract:
- The ACTION or SEQUENCE line → translate to keystrokes and send
- The QUIT line → gracefully quit the game, record the explanation
- Translate named keys (
space,enter,escape) to actual bytes - Handle direction-based actions that require two keystrokes (e.g.,
othenhto open a door to the west)
The QUIT response is important: if the bot determines it is stuck (e.g., trapped with no escape, confused by an unrecognizable game state, or in an unwinnable position), it should quit cleanly rather than thrash. The quit explanation is recorded and becomes valuable eval data — it tells us exactly what situations the bot can't handle yet.
Model selection strategy:
- Use
claude-sonnet-4-20250514for routine decisions (movement, simple combat) — faster and cheaper - Optionally escalate to
claude-opus-4-20250514for complex decisions (inventory management, wish decisions, endgame navigation) - This can be controlled by a "difficulty estimator" — if multiple monsters visible, low HP, or in endgame, use the stronger model
Token budget considerations:
- System prompt: ~2,000 tokens (game knowledge, symbol reference)
- Current screen state: ~500–1,000 tokens (map grid, stats, messages)
- History: ~1,000–2,000 tokens (last 10-20 turns)
- Run context: ~500 tokens (inventory summary, accomplishments)
- Total per turn: ~4,000–5,500 input tokens
- At ~$3/M input tokens (Sonnet), that's ~$0.015 per turn
- A typical ascension run is ~30,000–80,000 turns
- Estimated cost per run: $450–$1,200 with Sonnet, much more with Opus
This cost is significant. Optimizations include: batching multiple trivial moves (long corridors), caching repeated states, and only calling the LLM when the game state actually changes meaningfully.
3.5 Decision Engine / Brain (pkg/brain)
Purpose: Orchestrate the game loop and manage turn-by-turn flow.
Main loop:
func (b *Brain) Run(ctx context.Context) error {
for {
// 1. Read PTY until stable (no new bytes for N ms)
b.terminal.WaitForStable(50 * time.Millisecond) // faster than telnet — local PTY
// 2. Parse game state from terminal buffer
state := b.parser.Parse(b.terminal.GetScreen())
// 3. Record game state
b.recorder.RecordState(state.Turns, state)
// 4. Check for death / game over
if state.IsGameOver() {
b.recorder.EndRun(b.buildRunResult(state, "death"))
return nil
}
// 5. Handle mechanical prompts without LLM
if action := b.handleMechanical(state); action != "" {
b.recorder.RecordMechanical(state.Turns, action.Trigger, action.Keys)
b.execute(action.Keys)
continue
}
// 6. Check for stuck detection (pre-LLM)
if b.stuckDetector.IsHardStuck() {
reason := b.stuckDetector.Reason()
b.recorder.RecordQuit(state.Turns, "stuck_detected", reason)
b.quitGame()
b.recorder.EndRun(b.buildRunResult(state, "quit_stuck"))
return nil
}
// 7. Build context and query LLM
prompt := b.buildPrompt(state)
response, err := b.llm.Query(ctx, prompt)
// 8. Parse response — may be action, sequence, or quit
decision := b.parseResponse(response)
b.recorder.RecordDecision(state.Turns, decision)
// 9. Handle QUIT response from LLM
if decision.IsQuit {
b.recorder.RecordQuit(state.Turns, "llm_quit", decision.QuitReason)
b.quitGame()
b.recorder.EndRun(b.buildRunResult(state, "quit_llm"))
return nil
}
// 10. Execute action(s)
for _, key := range decision.Actions {
b.execute(key)
time.Sleep(20 * time.Millisecond) // Brief pause between keystrokes
}
// 11. Wait for outcome, record it
b.terminal.WaitForStable(50 * time.Millisecond)
outcome := b.assessOutcome(state)
b.recorder.RecordOutcome(state.Turns, outcome)
// 12. Update memory and stuck detector
b.memory.RecordTurn(state, decision, outcome)
b.stuckDetector.RecordTurn(state, decision)
}
}
// quitGame sends the quit sequence to NetHack and confirms
func (b *Brain) quitGame() {
b.execute('#')
b.terminal.WaitForStable(50 * time.Millisecond)
b.executeString("quit\n")
b.terminal.WaitForStable(50 * time.Millisecond)
b.execute('y') // "Really quit?" → yes
// NetHack will then show score and exit
}
Stuck detection (brain/loop_detect.go):
The stuck detector runs independently of the LLM, catching degenerate behavior:
type StuckDetector struct {
positionHistory []Position // ring buffer of recent positions
actionHistory []string // ring buffer of recent actions
windowSize int // configurable, default 20
threshold int // repeated positions before "stuck", default 5
}
func (s *StuckDetector) IsHardStuck() bool {
// Hard stuck conditions (quit without asking LLM):
// 1. Same position visited 5+ times in last 20 turns
// 2. Exact same action sequence repeated 3+ times
// 3. No HP/XP/DLevel change in 100+ turns
// (These are invariants — the bot is clearly not making progress)
}
func (s *StuckDetector) IsSoftStuck() bool {
// Soft stuck conditions (flag in prompt, let LLM decide):
// 1. Same position visited 3+ times in last 10 turns
// 2. No new map tiles explored in 30+ turns
// (These get injected into the LLM prompt as a warning)
}
When soft-stuck is detected, the prompt includes: "WARNING: You appear to be stuck. You have visited this position 3 times recently. If you cannot make progress, respond with QUIT and explain why." This gives the LLM a chance to try something creative before we force-quit.
Mechanical handlers (bypass LLM for speed/cost):
Some situations don't need LLM reasoning:
"--More--"→ send space"Really quit?"→ sendy(only reached when the bot has decided to quit)"Do you want your possessions identified?"→ sendy(death screen)- Corridor movement with only one obvious direction → send direction key
"There is a _ here; pick it up?"→ can be LLM or rule-based
Screen stability detection:
After sending a keystroke, NetHack redraws the screen via the PTY. We need to wait until the screen is "stable" before parsing — meaning no new bytes have arrived for a threshold period. With a local PTY this is much faster than over telnet (typically <10ms for NetHack to finish drawing), so we use a shorter timeout.
func (t *Terminal) WaitForStable(timeout time.Duration) {
// Read bytes until no new data for `timeout` duration
// With local PTY, 50ms is generous — NetHack redraws in <10ms
}
3.6 Memory System (pkg/memory)
Purpose: Maintain context across turns within the LLM's context window limits.
Components:
-
Turn History (rolling window):
- Last 15-20 turns: full map state + action + result
- Compressed to essential info: "Turn 1042: Moved east, saw goblin, killed it"
-
Run Summary (persistent, updated periodically):
- Current character: role, race, level, alignment
- Key inventory (weapons, armor, rings, amulets, important tools)
- Dungeon progress: levels visited, branches explored
- Major events: quest status, artifact found, etc.
- Current strategy/goal
-
Knowledge Base (static reference):
- Symbol reference sheet (what each character+color means)
- Common strategies (Sokoban solutions, sacrifice rules, etc.)
- Dangerous monsters by level (what to avoid)
-
Spatial Memory:
- Remembered map of visited levels (since the terminal only shows current level)
- Location of important features: altars, shops, stashes, stairs
Context window management:
Claude's context window is large but not infinite. The memory system must prioritize what to include:
Priority 1: Current screen + messages (always included)
Priority 2: Recent turn history (last 5 turns always, up to 20 if space)
Priority 3: Run summary (always included, kept concise)
Priority 4: Relevant knowledge (included based on current situation)
Priority 5: Spatial memory for current branch (if space permits)
The run summary should be regenerated every ~50 turns by asking the LLM to summarize the current run state, replacing the old summary.
3.7 Action Executor (pkg/action)
Purpose: Translate high-level actions to byte sequences and send them.
Action types:
| Action | Keys | Notes |
|---|---|---|
| Move direction | h/j/k/l/y/u/b/n |
vi-keys |
| Move until blocked | H/J/K/L/Y/U/B/N |
shift = run |
| Wait | . |
Single turn wait |
| Search | s |
Search for hidden doors/passages |
| Pickup | , |
Grab item from floor |
| Eat | e then inventory letter |
Two-step |
| Quaff | q then inventory letter |
Two-step |
| Read | r then inventory letter |
Two-step |
| Zap | z then inventory letter then direction |
Three-step |
| Open door | o then direction |
Two-step |
| Go upstairs | < |
Must be on < |
| Go downstairs | > |
Must be on > |
| Inventory | i |
Display only |
| Extended | # then command name then enter |
Variable length |
| Pray | #pray\n |
Extended command |
Multi-step action handling:
Many actions are multi-keystroke. The executor needs to handle intermediate prompts:
func (e *Executor) Eat(inventoryLetter rune) {
e.send('e')
e.waitForPrompt() // "What do you want to eat?"
e.send(inventoryLetter)
// May get follow-up: "There is a foo corpse here; eat it? [ynq]"
}
3.8 Recording System (pkg/recording)
Purpose: Produce the primary output artifact of every run — a rich, structured recording that captures everything needed for offline analysis and LLM-driven critique.
The recording system is not just logging — it's the foundation of the improvement loop. Every recording must be self-contained: a critic agent should be able to fully understand what happened, why the bot made each decision, and what the consequences were, without needing to replay the game.
Recording format — two complementary files per run:
File 1: TTYRec (visual replay)
Standard ttyrec format — raw byte stream with timestamps. Can be replayed with ttyplay, termplay, or any ttyrec-compatible tool. Useful for human review and visual debugging.
Filename: recordings/{run_id}.ttyrec
Format: [sec:4][usec:4][len:4][data:len] repeated
File 2: Decision Log (JSONL — machine-readable)
One JSON object per line, capturing every meaningful event. This is the primary input for the eval agent.
Filename: recordings/{run_id}.jsonl
Event types in the decision log:
// === Run metadata (first line) ===
{
"type": "run_start",
"run_id": "2026-03-11T14-30-00_val-dwa-neu",
"timestamp": "2026-03-11T14:30:00Z",
"nethack_version": "3.6.7",
"seed": 1741234567,
"character": {"role": "Valkyrie", "race": "Dwarf", "alignment": "Neutral", "gender": "Female"},
"config_hash": "a1b2c3d4", // Hash of .nethackrc + prompt templates for reproducibility
"prompt_version": "v3" // Which iteration of prompts was used
}
// === Game state snapshot (every turn where LLM is consulted) ===
{
"type": "state",
"turn": 1042,
"timestamp": "2026-03-11T14:35:12Z",
"map_ascii": "... (21×80 raw map) ...",
"player": {"pos": [34, 12], "hp": 45, "hp_max": 62, "pw": 12, "pw_max": 30,
"ac": 2, "xl": 8, "dlvl": 5, "dungeon": "Dungeons of Doom",
"str": "18/04", "dex": 14, "con": 18, "int": 7, "wis": 10, "cha": 10,
"hunger": "", "encumbrance": "", "conditions": [], "gold": 312, "turns": 1042},
"messages": ["The goblin hits you!", "You kill the goblin!"],
"visible_monsters": [{"char": "o", "color": "red", "pos": [36, 11], "name": "orc"}],
"nearby_items": [{"char": ")", "color": "cyan", "pos": [34, 13], "desc": "weapon"}],
"features": [{"type": "stairs_down", "pos": [40, 8]}]
}
// === LLM decision (the core record — one per LLM call) ===
{
"type": "decision",
"turn": 1042,
"timestamp": "2026-03-11T14:35:12Z",
"tier": "tactical",
"model": "claude-sonnet-4-20250514",
"prompt_tokens": 4200,
"completion_tokens": 85,
"latency_ms": 1240,
"prompt_summary": "Standard tactical — 1 monster visible, moderate HP",
"full_prompt_hash": "e5f6a7b8", // Can retrieve full prompt from prompt cache
"reasoning": "There's an orc 2 tiles NE. At HP 45/62 and XL 8, I should engage. Moving toward it via 'u' (up-right). My Excalibur should handle it easily.",
"action": "u",
"alternatives_considered": ["k (move north to avoid)", "s (search, waste of time)"],
"confidence": "high"
}
// === Mechanical action (no LLM involved) ===
{
"type": "mechanical",
"turn": 1043,
"timestamp": "2026-03-11T14:35:13Z",
"trigger": "more_prompt",
"action": " ",
"description": "Dismissed --More-- prompt"
}
// === Outcome / consequence tracking ===
{
"type": "outcome",
"turn": 1043,
"timestamp": "2026-03-11T14:35:13Z",
"hp_delta": -8,
"messages": ["The orc hits you!"],
"player_died": false,
"notable": false
}
// === Periodic summary (every N turns, for eval context) ===
{
"type": "summary",
"turn": 1050,
"timestamp": "2026-03-11T14:36:00Z",
"run_summary": "Valkyrie DL5, XL8, HP62. Have Excalibur. Cleared mines to ML3. Heading toward Sokoban.",
"inventory_snapshot": ["Excalibur (wielded)", "+1 small shield (worn)", "17 food rations", "..."],
"levels_visited": ["D:1-5", "Mines:1-3"],
"key_events_since_last": ["Found Excalibur at fountain on D:4", "Killed floating eye with ranged attack"]
}
// === Run end (last line) ===
{
"type": "run_end",
"turn": 2847,
"timestamp": "2026-03-11T15:12:00Z",
"cause": "killed_by", // "killed_by" | "quit_stuck" | "quit_llm" | "ascended" | "escaped"
"death_reason": "killed by a blue dragon", // null if quit
"quit_reason": null, // null if died; explanation string if quit
"final_score": 14230,
"deepest_level": 12,
"total_decisions": 847,
"total_mechanical": 2000,
"llm_cost_usd": 12.40,
"duration_minutes": 42,
"end_context": {
"hp_at_end": 0,
"hp_before_event": 34,
"monster": "blue dragon", // null if quit
"could_have_prayed": true,
"had_escape_item": false,
"last_reasoning": "Tried to melee the blue dragon. Should have used Elbereth or retreated.",
"stuck_details": null // populated if quit_stuck: position loop count, actions repeated, etc.
}
}
// === Example: bot quit because it was stuck ===
{
"type": "run_end",
"turn": 412,
"timestamp": "2026-03-11T14:42:00Z",
"cause": "quit_llm",
"death_reason": null,
"quit_reason": "I am trapped in a room with locked doors on all sides. I have no lock picks, no kick ability (wounded leg), and no spells. I've searched all walls 5 times with no hidden exits. There is no way to make progress.",
"final_score": 1230,
"deepest_level": 3,
"total_decisions": 120,
"total_mechanical": 292,
"llm_cost_usd": 1.80,
"duration_minutes": 6,
"end_context": {
"hp_at_end": 34,
"hp_before_event": 34,
"monster": null,
"could_have_prayed": true,
"had_escape_item": false,
"last_reasoning": "All exits are locked. I have no tools to open them. Quitting.",
"stuck_details": {"position_repeats": 8, "turns_without_progress": 45}
}
}
Full prompt caching:
To keep the JSONL manageable, full prompts are stored separately:
recordings/{run_id}/prompts/{hash}.txt
The decision log references prompts by hash. This avoids duplicating the system prompt (which is mostly static) thousands of times while keeping full reproducibility.
Recording writer interface:
type Recorder interface {
StartRun(config RunConfig) error
RecordState(turn int, state *GameState) error
RecordDecision(turn int, decision *Decision) error
RecordMechanical(turn int, trigger string, action string) error
RecordOutcome(turn int, outcome *Outcome) error
RecordQuit(turn int, cause string, reason string) error
RecordSummary(turn int, summary *RunSummary) error
EndRun(result *RunResult) error
Close() error
}
3.9 Eval / Critique Agent (cmd/eval)
Purpose: Offline tool that reviews game recordings and produces actionable improvement feedback.
This is a separate binary (nethack-llm-eval) that reads JSONL recordings and uses Claude to analyze decision quality. It's the "coach" that reviews game tape.
Eval modes:
Mode 1 — Death / Quit Autopsy
Triggered automatically after every run. Analyzes differently based on how the run ended:
For deaths — examines the final 20-30 turns leading up to death:
Input: Last 30 decision records + end_context from run_end
Output: Death autopsy report
Questions the eval answers:
- What killed the bot?
- When did the situation become dangerous? Which turn?
- What was the last decision that could have prevented death?
- Was the right information available in the prompt at that point?
- What rule or heuristic would have saved the bot?
For quits — examines how the bot got stuck and whether the quit was justified:
Input: Last 50 decision records + quit_reason + stuck_details
Output: Quit analysis report
Questions the eval answers:
- Was the bot actually stuck, or did it give up too early?
- Was there a creative solution it missed? (kick door, pray, dig, polymorph, etc.)
- How did it get into this situation? Which earlier decision led here?
- Is this a common stuck pattern? (If so, add a rule to the knowledge base)
- Should the prompt include escape strategies for this type of situation?
Mode 2 — Strategic Review
Analyzes an entire run at the summary/milestone level:
Input: All summary records + key decisions (inventory, branch choice, etc.)
Output: Strategic review report
Questions:
- Did the bot explore efficiently? (turns per level)
- Were branch decisions correct? (mines vs sokoban ordering)
- Was inventory management reasonable? (dropped good items? hoarded junk?)
- Were there missed opportunities? (altar not used, fountain not dipped, etc.)
- How does this run compare to typical human ascension runs at this stage?
Mode 3 — Decision Spot-Check
Samples N random decisions from the run and evaluates each one:
Input: N randomly sampled decision records with surrounding context
Output: Per-decision grades (good / acceptable / suboptimal / bad) with explanations
This catches:
- Systematic errors the bot makes repeatedly
- Situations where the bot's reasoning is sound but conclusion is wrong
- Prompt engineering gaps (bot didn't have needed info)
Mode 4 — Comparative Analysis
Compares two or more runs (e.g., before and after a prompt change):
Input: Two or more JSONL recordings
Output: Comparison report — what improved, what regressed, statistical differences
Eval output format:
// recordings/{run_id}.eval.json
{
"run_id": "2026-03-11T14-30-00_val-dwa-neu",
"eval_timestamp": "2026-03-11T16:00:00Z",
"eval_model": "claude-opus-4-20250514",
"death_autopsy": {
"root_cause": "Engaged blue dragon in melee without cold resistance",
"critical_turn": 2841,
"could_have_survived": true,
"missed_action": "Should have written Elbereth or used upstairs at turn 2841",
"knowledge_gap": "Bot prompt doesn't emphasize elemental dragon breath dangers",
"recommended_rule": "NEVER melee a dragon without the matching resistance. Flee or use Elbereth."
},
"strategic_review": {
"exploration_efficiency": "adequate — 45 turns/level average",
"branch_ordering": "good — mines before sokoban",
"inventory_management": "poor — carried 3 identified junk scrolls for 200+ turns",
"missed_opportunities": ["Didn't dip for Excalibur until DL:6 despite finding fountain on DL:4"]
},
"spot_check_results": {
"sample_size": 50,
"grades": {"good": 32, "acceptable": 11, "suboptimal": 5, "bad": 2},
"common_issues": [
"Repeatedly searches walls that have already been searched",
"Doesn't prioritize eating when hunger is 'Hungry' (waits until 'Weak')"
]
},
"recommended_prompt_changes": [
{
"section": "system/combat_rules",
"change": "Add: 'Never melee dragons without matching resistance. Blue=cold, Red=fire, etc.'",
"priority": "critical",
"evidence_turns": [2841, 2843, 2845]
},
{
"section": "system/hunger_rules",
"change": "Change eating threshold from 'Weak' to 'Hungry'",
"priority": "high",
"evidence_turns": [312, 567, 891]
}
]
}
Eval pipeline invocation:
# Run death autopsy on most recent run
nethack-llm-eval --mode=autopsy recordings/latest.jsonl
# Full review
nethack-llm-eval --mode=full recordings/2026-03-11T14-30-00.jsonl
# Compare two runs
nethack-llm-eval --mode=compare recordings/run1.jsonl recordings/run2.jsonl
# Spot-check with custom sample size
nethack-llm-eval --mode=spot-check --samples=100 recordings/latest.jsonl
4. Prompt Engineering Strategy
This is where the project succeeds or fails. The LLM's ability to play NetHack well depends entirely on how we frame the game state and guide its reasoning.
4.1 State Representation
Option A — Raw ASCII map:
Send the actual 80×24 screen (or the 80×21 map portion) as text. This is simple and preserves all information, but uses more tokens and requires the LLM to interpret raw symbols.
--------
|......|
|......+########
|..@...| #
-------- #
#
########
#
-------+------
|.............|
|.............|
---------------
Option B — Structured representation:
Parse the map into a structured format with annotated entities:
Player at (21, 3). Current room: 8×4, exits: east door (open).
Visible entities:
- Goblin (g) at (24, 5), 2 tiles SE
- Food ration at (20, 3), 1 tile W
Dungeon features:
- Open door at (25, 3)
- Corridor leading east from door
- Staircase down at (19, 10)
Recommendation: Use both. Send the raw map (the LLM can read ASCII maps) plus structured annotations for key entities. This gives the LLM spatial reasoning ability while ensuring it doesn't miss important details.
4.2 Decision Tiers
Not all decisions are equal. We should tier them:
Tier 1 — Reflexive (no LLM needed):
- Press space on "--More--"
- Move through a corridor with only one exit
- Pick up gold automatically
Tier 2 — Tactical (fast model, simple prompt):
- Which direction to explore
- Whether to fight or flee a single monster
- Whether to pick up a visible item
- Basic movement decisions
Tier 3 — Strategic (full prompt, possibly stronger model):
- Inventory management (what to keep, drop, use)
- Whether to descend or keep exploring current level
- Handling shops, altars, fountains
- Eating decisions (corpse safety, timing)
- Equipment optimization
Tier 4 — Critical (strongest model, extended reasoning):
- Wish decisions
- Genocide targets
- Near-death crisis management
- Endgame navigation
- Quest decisions
4.3 Prompt Templates
Minimal tactical prompt:
[NetHack Turn {turn} | DL:{dlvl} | HP:{hp}/{hpmax} | {hunger}]
MAP:
{raw_map_grid}
MSG: {last_message}
NEARBY: {annotated_entities}
STATS: STR:{str} DEX:{dex} CON:{con} INT:{int} WIS:{wis} CHA:{cha} AC:{ac} XL:{xl}
Last 5 actions: {recent_history}
What single keystroke should I press? Respond: THINK: <brief reasoning> then ACTION: <key>
Full strategic prompt:
[System: detailed NetHack knowledge base, symbol reference, strategy guide]
=== CURRENT STATE (Turn {turn}) ===
{full map with annotations}
=== PLAYER STATUS ===
{detailed stats, conditions, encumbrance}
=== INVENTORY SUMMARY ===
{categorized inventory}
=== RECENT EVENTS (last 15 turns) ===
{turn-by-turn history}
=== RUN CONTEXT ===
{run summary: progress, goals, strategy}
=== DECISION NEEDED ===
{specific situation description and what needs to be decided}
4.4 Known LLM Weaknesses to Mitigate
-
Spatial reasoning: LLMs struggle with exact grid coordinates. Mitigate with clear annotations ("goblin is 2 tiles east") and possibly a local neighborhood view (5×5 around player).
-
Memory across turns: The LLM has no persistent memory. Mitigate with the memory system feeding context each turn.
-
Obscure game knowledge: Claude knows NetHack but may confuse details. Mitigate with a knowledge base in the system prompt for critical rules (prayer cooldown, intrinsic resistances, safe corpses, etc.).
-
Repetitive behavior: LLMs can get stuck in loops (go east, go west, go east...). Mitigate with loop detection in the brain module — if the same position is visited 3+ times in 10 turns, flag it in the prompt.
-
Risk calibration: LLMs may be too aggressive or too cautious. Mitigate with explicit HP thresholds in the prompt ("if HP < 30%, consider retreating or praying").
5. Engineering Plan
5.1 Project Structure
nethack-llm/
├── cmd/
│ ├── nethack-llm/
│ │ └── main.go # Entry point — play bot
│ └── nethack-llm-eval/
│ └── main.go # Entry point — eval/critique tool
├── pkg/
│ ├── pty/
│ │ ├── driver.go # PTY allocation, subprocess launch, I/O
│ │ ├── driver_test.go
│ │ └── nethackrc.go # .nethackrc generation
│ ├── terminal/
│ │ ├── terminal.go # 80×24 cell buffer
│ │ ├── parser.go # VT100 escape sequence state machine
│ │ ├── parser_test.go # Test with known NetHack screen dumps
│ │ ├── cell.go # Cell type, color definitions
│ │ └── testdata/ # Captured screen sequences for testing
│ ├── gamestate/
│ │ ├── state.go # GameState struct
│ │ ├── parser.go # Screen → GameState extraction
│ │ ├── status.go # Status line parsing
│ │ ├── map.go # Map cell classification
│ │ ├── message.go # Message line + prompt detection
│ │ ├── symbols.go # Character+color → entity mapping
│ │ └── parser_test.go
│ ├── llm/
│ │ ├── client.go # Claude API HTTP client
│ │ ├── prompt.go # Prompt template construction
│ │ ├── response.go # Response parsing (ACTION/SEQUENCE extraction)
│ │ └── client_test.go
│ ├── brain/
│ │ ├── brain.go # Main game loop
│ │ ├── mechanical.go # Mechanical handlers (--More--, etc.)
│ │ ├── tiers.go # Decision tier classification
│ │ ├── loop_detect.go # Stuck/loop detection
│ │ └── quit.go # Graceful quit handling (send #quit, confirm)
│ ├── memory/
│ │ ├── history.go # Turn history ring buffer
│ │ ├── summary.go # Run summary management
│ │ ├── spatial.go # Per-level map memory
│ │ └── knowledge.go # Static game knowledge
│ ├── action/
│ │ ├── executor.go # Keystroke sending via PTY
│ │ ├── commands.go # Named command → keystroke mapping
│ │ └── multi_step.go # Multi-keystroke action handling
│ ├── recording/
│ │ ├── recorder.go # Recording coordinator
│ │ ├── ttyrec.go # TTYRec format writer
│ │ ├── jsonl.go # JSONL decision log writer
│ │ ├── events.go # Event type definitions
│ │ ├── prompt_cache.go # Full prompt dedup/storage by hash
│ │ └── reader.go # Read recordings (used by eval)
│ ├── eval/
│ │ ├── autopsy.go # Death analysis
│ │ ├── strategic.go # Full-run strategic review
│ │ ├── spot_check.go # Random decision sampling + grading
│ │ ├── compare.go # Multi-run comparison
│ │ └── report.go # Report generation (JSON + human-readable)
│ └── config/
│ └── config.go # Configuration loading
├── data/
│ ├── symbols.json # Monster/item symbol database
│ ├── knowledge.md # NetHack knowledge base for prompts
│ └── prompts/ # Prompt templates
│ ├── system.txt # Main system prompt
│ ├── tactical.txt # Tier 2 decisions
│ ├── strategic.txt # Tier 3 decisions
│ ├── eval_autopsy.txt # Eval agent: death analysis prompt
│ ├── eval_strategic.txt # Eval agent: strategic review prompt
│ └── eval_spotcheck.txt # Eval agent: decision grading prompt
├── recordings/ # Output directory for game recordings
│ └── .gitkeep
├── go.mod
├── go.sum
├── Makefile
└── README.md
5.2 Sprint Plan
Sprint 1 (Week 1-2): Foundation — PTY + Terminal + Recording
| Task | Est. Hours | Description |
|---|---|---|
| Project scaffolding | 2 | Go module init, directory structure, Makefile |
| PTY driver | 6 | Subprocess launch, PTY allocation, byte stream I/O via creack/pty |
| .nethackrc generator | 3 | Generate bot-optimized config, manage NETHACKOPTIONS env |
| VT100 state machine | 16 | Escape sequence parser, cell buffer, cursor handling |
| TTYRec writer | 3 | Record raw byte stream in ttyrec format |
| JSONL writer skeleton | 3 | Recording infrastructure, event types, run start/end |
| Integration test | 4 | Launch NetHack, dump parsed screen, verify round-trip |
| Total | 37 |
Deliverable: Launch local NetHack, parse the screen, produce a ttyrec + skeleton JSONL recording.
Sprint 2 (Week 3-4): Game State Parser
| Task | Est. Hours | Description |
|---|---|---|
| Status line parser | 6 | Regex-based HP/stats/conditions extraction |
| Message line parser | 4 | Top message, --More-- detection |
| Prompt detection | 8 | Direction, YN, inventory, menu prompts |
| Map cell classification | 8 | Symbol + color → entity type mapping |
| Monster/item database | 6 | Populate symbols.json with NetHack data |
| Overlay screen detection | 4 | Inventory/menu vs normal map |
| Game state tests | 6 | Test against captured screen data |
| Total | 42 |
Deliverable: Full structured game state extraction from live game screen.
Sprint 3 (Week 5-6): LLM Integration + Basic Bot + Recording
| Task | Est. Hours | Description |
|---|---|---|
| Claude API client | 6 | HTTP client, auth, retry logic |
| Prompt templates | 10 | System prompt, tactical prompt, state formatting |
| Response parser | 4 | Extract ACTION/SEQUENCE from LLM response |
| Action executor | 6 | Keystroke sending via PTY, multi-step actions |
| Mechanical handlers | 4 | --More--, corridor following, trivial prompts |
| Main game loop | 8 | Brain module, turn flow, stability detection |
| Full recording integration | 6 | Wire recorder into game loop — state, decision, outcome events |
| Prompt cache | 3 | Hash-based dedup of full prompts for recording storage |
| Total | 47 |
Deliverable: Bot plays NetHack autonomously, exploring and fighting. Produces complete JSONL recordings with every decision and its reasoning. Likely dies frequently but records everything.
Sprint 4 (Week 7-8): Memory + Survival
| Task | Est. Hours | Description |
|---|---|---|
| Turn history | 6 | Rolling window of recent turns |
| Run summary | 8 | Periodic LLM-generated summary |
| Spatial memory | 10 | Per-level explored map storage |
| Knowledge base | 8 | Critical NetHack rules for prompt injection |
| Loop detection | 4 | Detect and flag repetitive behavior |
| Hunger management | 4 | Prompt tuning for eat timing |
| Total | 40 |
Deliverable: Bot survives significantly longer, manages food, remembers where it's been.
Sprint 5 (Week 9-10): Decision Quality + Inventory
| Task | Est. Hours | Description |
|---|---|---|
| Decision tier system | 6 | Classify situations by complexity |
| Model selection | 4 | Route to Sonnet vs Opus by tier |
| Inventory management prompts | 10 | Equipment optimization, drop decisions |
| Strategic prompt template | 8 | Full context for complex decisions |
| Prayer/emergency handling | 4 | Crisis detection and response |
| Cost optimization | 6 | Skip LLM for trivial moves, batch corridors |
| Total | 38 |
Deliverable: Bot makes smarter decisions, manages inventory, handles emergencies.
Sprint 6 (Week 11-12): Eval Agent + Improvement Loop
| Task | Est. Hours | Description |
|---|---|---|
| Recording reader | 4 | Parse JSONL recordings for eval consumption |
| Death autopsy eval | 8 | Analyze final turns, identify root cause, suggest rules |
| Strategic review eval | 8 | Full-run analysis, efficiency metrics, missed opportunities |
| Spot-check eval | 6 | Random decision sampling, grading, pattern detection |
| Eval report generation | 4 | JSON + human-readable output |
| Prompt iteration pipeline | 6 | Apply eval recommendations → update prompts → re-run → compare |
| Documentation | 4 | README, setup guide, recording format spec, eval usage |
| Total | 40 |
Deliverable: Complete improvement loop — bot plays, eval agent critiques, findings feed back into prompts. Comparative analysis shows measurable improvement across runs.
5.3 Total Estimate
| Phase | Weeks | Hours |
|---|---|---|
| Sprint 1: Foundation (PTY + Terminal + Recording) | 2 | 37 |
| Sprint 2: Game State | 2 | 42 |
| Sprint 3: LLM + Bot + Recording | 2 | 47 |
| Sprint 4: Memory + Survival | 2 | 40 |
| Sprint 5: Decisions + Inventory | 2 | 38 |
| Sprint 6: Eval Agent + Improvement Loop | 2 | 40 |
| Total | 12 weeks | 244 hours |
6. Key Technical Risks
6.1 VT100 Emulation Fidelity
Risk: Incomplete escape sequence handling causes garbled screen parsing.
Mitigation: Record raw PTY byte streams from real game sessions and build a comprehensive test suite. Start with the most common sequences and add more as issues arise. Local execution makes it easy to capture test data — just tee the PTY output. Consider using go-vte as a base and extending it.
6.2 Screen Stability / Timing
Risk: We parse the screen mid-update, getting a half-drawn frame.
Mitigation: Wait for a quiescent period (no new bytes for 50ms) before parsing. With local PTY this is much less of an issue than over telnet — NetHack typically finishes screen redraws in <10ms locally. The 50ms timeout is generous and reliable.
6.3 Prompt Quality
Risk: The LLM makes poor decisions, gets stuck in loops, or misunderstands the game state.
Mitigation: Iterative prompt engineering based on replay analysis. Build a feedback loop: play → record → analyze deaths → improve prompts → repeat. The logging/replay system is essential infrastructure for this.
6.4 Cost
Risk: At $0.015/turn with potentially 50K+ turns per run, costs add up fast.
Mitigation: Aggressive use of mechanical handlers, corridor batching, and tier-based model selection. Most turns in NetHack are "walk down this corridor" — these don't need an LLM call. Target <30% of turns needing LLM involvement.
6.5 Eval Agent Quality
Risk: The eval/critique agent produces vague or unhelpful feedback that doesn't translate to actionable prompt improvements.
Mitigation: Design eval prompts to demand specificity — every critique must cite exact turn numbers, quote the bot's reasoning, and propose a concrete rule change. Grade eval output quality manually for the first several runs. Iterate on eval prompts just as aggressively as gameplay prompts.
6.6 Recording Volume
Risk: JSONL recordings become very large for long runs (50K+ turns × detailed state snapshots).
Mitigation: Only record full state snapshots on turns where the LLM is consulted. Mechanical turns get minimal records. Prompt dedup via hash-based cache avoids storing the (mostly static) system prompt thousands of times. Target <50MB per run for the JSONL, which is manageable.
6.6 LLM Hallucination on Game Rules
Risk: Claude misremembers NetHack rules (e.g., which corpses are safe to eat, prayer cooldown timing).
Mitigation: Include critical rules explicitly in the knowledge base injected into the system prompt. Don't rely on the LLM's training knowledge for safety-critical game decisions.
7. Configuration
# config.yaml
game:
binary: "/usr/bin/nethack" # Path to nethack executable
nethackrc: "auto" # "auto" = generate bot-optimized, or path to custom
seed: 0 # 0 = random, any other value = deterministic (NH 3.7+)
character:
role: "Valkyrie"
race: "Dwarf"
alignment: "Neutral"
gender: "Female"
terminal:
width: 80
height: 24
stability_timeout_ms: 50 # Faster than telnet — local PTY
llm:
api_key: "${ANTHROPIC_API_KEY}"
default_model: "claude-sonnet-4-20250514"
strategic_model: "claude-opus-4-20250514"
max_tokens: 300
temperature: 0.3 # Lower = more consistent play
retry_attempts: 3
retry_delay_ms: 1000
brain:
mechanical_handlers: true
corridor_batching: true
loop_detection_window: 10
loop_threshold: 3
decision_delay_ms: 20 # Pause between keystrokes (fast — local)
memory:
history_window: 20 # Turns of full history
summary_interval: 50 # Regenerate summary every N turns
spatial_memory: true
recording:
dir: "./recordings"
ttyrec: true # Visual replay file
jsonl: true # Structured decision log
prompt_cache: true # Dedup full prompts by hash
full_state_on_mechanical: false # Skip full state snapshots for mechanical turns
eval:
model: "claude-opus-4-20250514" # Use strongest model for critique
autopsy_context_turns: 30 # How many turns before death to analyze
spot_check_sample_size: 50 # Decisions to sample per run
log_level: "info"
8. Dependencies
| Dependency | Purpose | Go Module |
|---|---|---|
| PTY allocation | Launch NetHack subprocess | github.com/creack/pty |
| HTTP client | Claude API | net/http (stdlib) |
| JSON | API serialization, recording | encoding/json (stdlib) |
| YAML | Configuration | gopkg.in/yaml.v3 |
| CLI flags | Command-line interface | github.com/spf13/cobra or flag (stdlib) |
| Logging | Structured logging | log/slog (stdlib, Go 1.21+) |
| Hashing | Prompt dedup in recordings | crypto/sha256 (stdlib) |
| Testing | Unit + integration tests | testing (stdlib) |
Intentionally minimal. Go's standard library covers most needs. The only required external dependency is creack/pty for pseudoterminal allocation and a YAML parser for config.
9. Future Considerations (Post-v1)
NAO/remote mode: Add telnet support to play on nethack.alt.org or hardfought.org for competitive play and tournaments. The PTY driver interface is abstract enough that a telnet driver can implement the same interface.
Multi-agent architecture: Use separate LLM "specialists" for different aspects — a tactical combat agent, an inventory manager, a navigation planner — coordinated by a meta-agent.
Automated improvement pipeline: Chain play → eval → prompt update → play into a fully automated loop. Run N games, eval all recordings, generate consolidated prompt improvements, apply them, run N more games, measure improvement. This is the end goal of the recording architecture.
Fine-tuning: Collect successful game transcripts and fine-tune a smaller model specifically for NetHack decision-making. The JSONL recordings are already in the right format for training data extraction.
Monte Carlo Tree Search hybrid: For critical decisions (wishes, genocide), generate multiple candidate actions and simulate outcomes before committing.
Web dashboard: Real-time visualization of the bot playing, with game state, LLM reasoning, and statistics displayed in a browser. Recordings can also be replayed in the dashboard.
Eval leaderboard: Track performance metrics across runs (deepest level, turns survived, score, XP) and correlate with prompt versions to measure improvement over time.
Deterministic replay: Using NH 3.7+ seed support, replay interesting runs with different prompts to see if a strategy change would have helped at a specific point.
10. Resolved Decisions
-
NetHack version: Use whatever is installed on the system (latest). The bot detects the version at startup and logs it in the run metadata.
-
Character selection: Configurable, defaulting to Valkyrie (Dwarven, Neutral) for v1. The eval pipeline is scoped per-character-type — prompt templates and knowledge base entries may differ across roles. Future roles will need their own eval baselines.
-
NetHack installation: Assumed pre-installed. The bot expects a
nethackbinary on PATH (or configured viagame.binaryin config). No Docker or install scripts in v1. -
Eval agent model: Configurable, defaulting to Opus. Opus produces higher-quality critique and we run eval infrequently compared to gameplay, so the cost is acceptable.
-
Checkpoint/resume: We rely on NetHack's built-in save system. If the bot process crashes, NetHack has already saved state. On restart, the bot detects an existing save file and resumes. We do not build custom memory checkpointing — the recording system captures everything needed, and the memory module can reconstruct from the current game screen.
-
Parallel runs: Future work. The local PTY architecture makes this trivial (launch N processes), but we want the single-run improvement loop working well first.
-
Stuck handling: The bot can quit. If the LLM determines it is stuck and cannot make progress, it responds with
QUIT: <explanation>. Additionally, a hardcoded stuck detector force-quits if the bot is looping (same position 5+ times in 20 turns, or no progress in 100+ turns). Both quit paths record a detailed explanation that feeds into the eval system. This is strictly bot-only — no human-in-the-loop.