Skip to content

About

An llm harness for playing Zork

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

21 Commits

Folders and files

Repository files navigation

Adventure Player

An autonomous player for Z-machine text adventures. It runs a game through dfrotz, lets a modular command chooser decide what to do, and prints one scrolling transcript containing the game, the model's visible reasoning, tool calls, memory updates, and commands.

The default LM Studio player has four tools: play one command, replace its durable Markdown memory, save the game, and restore a save. Recent play is bounded before every model request; anything that must survive belongs in memory.md.

Alongside the transcript the player keeps a journal in saves/journal.json: the current room, score and moves, what each room has already answered, which directions turned out to be walls, and which exits have actually been walked. That block is handed to the model every turn so it does not have to re-derive its own position, and repeat questions the journal can already answer are refused before they cost a move. The game is put into verbose mode at startup, a progress save is written every time the score rises, and the model is made to rewrite its memory on resume and every ten commands so stale notes cannot start an argument with the game.

Setup

Install dfrotz and load a tool-capable model in LM Studio under the expected identifier:

brew install frotz
lms server start
lms load <small-tool-capable-model> --identifier adventure-player --gpu max -c 16384 -y
bun install

Games are not distributed here. Pass the path to a .z3, .z5, or .z8 story file:

bun start -- /path/to/zork1.z5

For the existing Zork and dfrotz copies under ~/src/martinemde, using Jev:

bun zork

Use bun zork --player=local to select LM Studio instead.

If dfrotz is not on PATH, or the loaded model uses another identifier:

bun start -- /path/to/zork1.z5 \
  --dfrotz=/path/to/dfrotz \
  --model=my-loaded-model

Stop with Ctrl-C. The player saves to saves/resume.qzl before shutting down, then restores that session automatically the next time it starts. Durable agent notes remain readable in memory.md, and saves/journal.json carries the map and per-room findings across restarts. Delete either to start the notes over.

Jev through OpenRouter

Put OPENROUTER_API_KEY in this project's .env (ignored by version control; Bun loads it automatically), then run:

bun start -- /path/to/zork1.z5 --player=jev --dfrotz=/path/to/dfrotz

Jev defaults to typesafe/jev-1.13 and does not need LM Studio. Its session is separate: saves and the journal live in saves/jev/. --model, --saves, and --memory override the defaults. The local player remains the default.

The design follows TypeSafe's function-calling cookbook and pre-parsed extraction cookbook: code owns the available values and Jev chooses among them. One request chooses a command template or direction; object commands use a second request to choose noun spans from recent game text and the journal's inventory. Two-object commands batch both argument questions. Every returned choice is checked against its menu before reaching the game. Requests use OpenRouter's Decisions API, not chat completions.

Jev's decisions and confidence appear in the transcript. It does not generate reasoning or rewrite Markdown memory; the journal and bounded recent transcript carry its observations. Automatic progress saves and Ctrl-C/resume work for both players. Jev can restore a progress save, but does not choose custom save points.

This is an experimental player: its finite command grammar and short text spans cannot express every Zork puzzle, and mentioned objects are not necessarily reachable. Low-confidence choices still run; the confidence is evidence for improving the policy, not a guarantee of progress. A 15-command smoke run opened the mailbox, took and read the leaflet, and explored the forest, but earned no points and began backtracking.

src/chooser.ts defines the extension boundary. Implement choose(state, signal) as an async generator of actions; each yield receives the actual execution result. Only the shared player executes commands, handles saves, and updates the journal. A chooser may optionally implement refreshMemory if it can produce prose.

Gemma/Jev hybrid evals

src/hybrid-eval.ts investigates Jev as a narrow reviewer of a proposed command, with Gemma retaining reasoning and command generation. Start LM Studio's API server with Gemma 12B loaded, then run:

bun src/hybrid-eval.ts "$HOME/src/martinemde/text_player/games/zork1.z5" \
  "$HOME/src/martinemde/ai_player/frotz/dfrotz" checks

checks gives both models the same real Zork observations and eight proposed commands, including valid alternatives after a failure. Human-specified expected labels stay out of model context. jev runs only Jev's side when the local API is unavailable. shadow runs the existing Gemma player for up to 15 commands (four-minute timeout); Jev records judgments without influencing execution. All modes isolate saves under saves/hybrid-evals/ and record inputs, responses, timings, and game output in trace.jsonl. These are live evals, not mock tests.

The first Jev-only pass matched all eight expected labels at approximately 0.17–0.45 seconds per judgment. That establishes feasibility on these small fixtures, not improved play or an advantage over Gemma. Measure false rejections and actual Gemma mistakes before giving Jev veto power; successful narrow classification does not justify delegating world-state extraction or planning.

The paired checks run with google/gemma-4-12b-qat matched all eight labels for both models. Mean request latency was 0.19 seconds for Jev and 7.45 seconds for Gemma with the existing 256-token thinking budget and 512-token output cap; this is not a comparison against a non-reasoning Gemma classifier. Trace: saves/hybrid-evals/2026-09-28T06-41-13.358Z/trace.jsonl.

The subsequent shadow run completed 15 commands at score zero. Gemma acquired the leaflet and revealed the locked grating, then backtracked through the forest. Jev allowed every command and supplied no corrective signal. Trace: saves/hybrid-evals/2026-09-28T06-42-26.843Z/trace.jsonl. This reviewer checks evidence contradictions, not whether movement advances a plan. Before adding it to the live player, evaluate a role tied to an explicit Gemma-generated objective and its completion conditions. These traces are local ignored eval artifacts.

The planned mode tests that next hypothesis: gemma-planner.ts generates a short goal, completion condition, and candidate commands with applicability and expected results. planned-chooser.ts asks Jev to assess completion/progress and select a candidate. It replans on completion, blockage, no applicable candidate, two steps without confirmed progress, four executed steps, or action confidence below 0.55. That confidence threshold is an experimental heuristic, not a calibrated measure. After two unsuccessful planning/selection rounds it delegates one action to the ordinary Gemma chooser. Plans are session-local; this is an eval mode, not a new default player.

Run the same command above with planned and then baseline to compare fresh games, each capped at 15 commands or four minutes. The baseline uses the existing Gemma chooser without Jev. Traces include Gemma requests/responses, Jev decisions, game output, call counts, and summaries; no model mocking is involved. These use different prompts and control policies, so the comparison is exploratory rather than an isolated measurement of model quality.

The first planned run stopped after seven commands and nine Gemma calls at zero points. It exposed go north versus bare-direction journal parsing and repeated low-confidence replanning. After direction normalization and bounded direct-action fallback, the planned run hit four minutes with 12 attempted commands, 16 Gemma calls, and zero points. Five commands such as look leaflet were parser errors. It also generated weak completion conditions, such as counting departure as completion of investigating a tree. The fresh baseline reached the same time limit with 13 commands, 17 Gemma calls, and zero points, but did reveal the locked grating. No improvement was demonstrated. Parser validity and plan quality must be established before treating this as evidence for or against Jev execution. The comparison traces are saves/hybrid-evals/2026-09-28T06-54-40.065Z/trace.jsonl and saves/hybrid-evals/2026-09-28T06-58-59.066Z/trace.jsonl.

Experimental goal-thread player

Run bun zork --player=threads. This chooser uses Jev to select memorable phrases and observed object names, classify object properties, review retained evidence, and choose an objective followed by a complete command. Code constructs acquire/open/read/inspect goals from those classifications. Ordinary movement remains an option alongside goal work. Unknown passages can be exploration probes; a model abstention stops the run with an explicit error.

The implementation separates the API and tracing (jev-decisions.ts), observation questions (thread-observation.ts), JSON memory and goal transitions (thread-memory.ts), candidate commands (thread-actions.ts), and orchestration (thread-chooser.ts). It shares the existing player/execution boundary. Defaults are separate saves in saves/threads/ and memory in saves/threads/memory.json. Pass both --saves and --memory when isolating another session.

Only new command/results update observed state. Acquisition effects update possession; movement changes the player location without moving remembered objects. Failed goals retain the exact obstacle response and are reconsidered when Jev identifies new evidence that removes it. Superseded evidence is retained as history. A parser rejection disables further commands for that object noun. Explicit restoration discards thread state instead of carrying future facts back into an earlier game. Full checkpoint reconciliation is not implemented.

Evaluate real Jev decisions against real Zork, without mocking the model:

bun run eval:threads "$HOME/src/martinemde/text_player/games/zork1.z5" \
  "$HOME/src/martinemde/ai_player/frotz/dfrotz"

The bounded eval supplies the tree encounter but no target or objective. It runs up to eight commands and records exact API requests/responses, confidence, provider usage, memory snapshots, and game responses under saves/thread-experiments/. result.json contains checks against the final game inventory and stored goals; errors and failed checks exit nonzero. These live evals are intentionally separate from bun test.

Append start to the eval command to run up to 30 commands from the actual starting position instead of supplying the tree encounter. This scenario checks whether any points were earned and preserves early errors in the trace. It is a necessary countercheck to the staged encounter: the current build can open the mailbox, miss its newly revealed leaflet, attribute readability to the mailbox, and stop without scoring. Selecting only one object per sentence loses evidence; property attribution and destination-free exploration remain unresolved. Do not treat the staged egg checks as evidence of a capable autonomous player.

The current encounter eval discovers acquisition, takes the egg, retains its opening obstacle, rejects an unsupported noun, and continues exploring. It also exposes false property attribution: a closed egg in the description can produce an unsupported “open tree” goal. Passing the acquisition checks does not mean all decisions were good. The next eval target is attributing each property to the correct object. This build has no general puzzle synthesis, tool-combination grammar, reliable alias resolution, or complete survival policy yet.

Bounded egg experiment

Run a fresh local Zork encounter against the real Jev API, using the same OPENROUTER_API_KEY as the player:

bun src/egg-experiment.ts "$HOME/src/martinemde/text_player/games/zork1.z5" \
  "$HOME/src/martinemde/ai_player/frotz/dfrotz"

This supplies the route and the target egg; it does not test objective discovery. It records the current chooser's proposed commands, batches separate fact questions, and applies a fixed acquisition rule. Unknown portability permits a take probe when the egg is nearby, valuable, and not carried. A failed opening does not block acquisition. The real game's inventory checks ownership after descending. Controls require no acquisition when below the tree or already carrying the egg. Each run uses its own directory under saves/experiments/ and writes result.json with request evidence snapshots, answers, and checks. A failed experimental check exits nonzero; this live experiment is separate from the offline test suite.

The control exposes a temporal reasoning problem: questions over the whole transcript can retain “beside the player” after descending, or assert that the egg is both carried and still in the nest. Separate fact questions alone do not provide reliable current state. The next experiment should classify changes from individual command/result pairs and let code maintain location and containment. Successful acquisition in this fixture is not evidence of a general planner.

Development

bun run check

Tests fake both external boundaries—the model and the dfrotz process—so they cannot reach LM Studio, a network service, an account, or a real game. A manual smoke run is the separate hardware verification.

About

An llm harness for playing Zork

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages