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.
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 installGames are not distributed here. Pass the path to a .z3, .z5, or .z8
story file:
bun start -- /path/to/zork1.z5For the existing Zork and dfrotz copies under ~/src/martinemde, using Jev:
bun zorkUse 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-modelStop 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.
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/dfrotzJev 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.
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" checkschecks 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.
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.
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.
bun run checkTests 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.