An open-source environment harness for autonomous agents, set in Dwarf Fortress. Agents play a live fortress one action per step; the harness records every observation, state, and action to JSONL and streams public, replayable evidence.
Fort-Gym is the harness beneath Fort-Eval, Fort Labs' benchmark for planning, spatial memory, resource management, and adaptation in a procedurally generated civilization. Official model comparisons require the same versioned protocol, code, seed cohort, score, and budgets; older runs remain public evidence rather than a universal ranking.
The current Year-Two Autonomous Play program starts with Astra Medium on standard game controls, retains explicitly labelled DFHack shortcuts as a comparison condition, and targets a functioning fortress into year two plus repeated evaluation across at least three models. It is not the historical governed-only gate ladder and has not yet achieved its full objective.
The native campaign runtime adds configuration-driven model selection, copied native runtimes, persistent checkpoints, serial continuation, and inspectable progress. Its agent foundation preserves memory and cumulative usage. This is an experimental checkout-based runtime, not a completed year-two result or a cross-model ranking. For the integrated keyboard runner, six-attempt model comparison and playable recordings, start with the current Year-Two guide. It separates current entry points from historical experiment notes and explains which setup commands need no model, game installation or private runtime assets. The keyboard recovery contract distinguishes model input rejection from uncertain delivery and preserves interrupted evidence.
What's in the box
- Two backends: a deterministic mock environment for development and CI, and a DFHack-powered backend for live fortress runs on Linux
- One-action-per-step protocol with full JSONL trace capture of every observation, state, and action
- Live run streaming over SSE, with admin and public web UIs
- Job orchestration for 10-run batches, with summaries and a leaderboard
- LLM and agent action adapters — bring your own model
- Ansible-based deploy for running live benchmarks on a GCP VM
git clone https://github.com/lemoz/fort-gym.git
cd fort-gym
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -e '.[dev]'
fort-gym quickstartThe quickstart command runs a local mock benchmark, records the trace and summary,
creates a local public share token, then serves the API on 127.0.0.1:8000. Open
http://127.0.0.1:8000/leaderboard to inspect the leaderboard. This path does not
require DFHack or an API key.
If port 8000 is already in use, fort-gym quickstart automatically serves on the
next available localhost port. You can also choose one explicitly with
fort-gym quickstart --port 8018.
If you only want to generate the run artifacts and start the server later:
fort-gym quickstart --no-serve
FORT_GYM_INSECURE_ADMIN=1 fort-gym api --no-reloadArtifacts (trace JSONL + summary) land under fort_gym/artifacts/<run_id>/. You
can replay them via the public UI (/live) or inspect the historical leaderboard
(/leaderboard).
Run the built-in drink-scarcity scenario pack with assertion checks:
fort-gym scenario-run drink-scarcityThe scenario starts the mock fortress below the safe drink threshold and writes
assertion results into summary.json under scenario_assertions.
To run an LLM agent on the mock backend, install the optional agent dependencies:
python -m pip install -e '.[agent]'The default LLM provider is OpenRouter (OPENROUTER_API_KEY, model
z-ai/glm-5.2). Legacy Anthropic agents still exist in the codebase but are
disabled by the API server unless FORT_GYM_ENABLE_ANTHROPIC=1 is set;
examples/claude-sonnet-4-6-mock/ is a historical committed trace from that
era, kept for reference.
For local development on macOS using the Lazy Mac Pack with DFHack, see MAC_SETUP.md for complete instructions including:
- Configuring DF for remote access (TEXT mode, remote plugin)
- Setting
DFROOTenvironment variable - Installing hook scripts
- Running the API against local DF on port 5000
The same fort-gym codebase works on both Mac and Linux—only the DFROOT path differs.
- Google Cloud SDK (
gcloud) configured with project/zone. - VM firewall needs TCP 80/443 for HTTPS.
- DFHack RPC should stay loopback-only; use SSH tunnels if you need access from your laptop.
gcloud compute firewall-rules create allow-fortgym-http-https --allow tcp:80,tcp:443 --direction=INGRESS --target-tags=fortgym
gcloud compute instances create dfhack-host --machine-type=e2-standard-2 --image-family=ubuntu-2204-lts --image-project=ubuntu-os-cloud --boot-disk-size=50GB --tags=dfhack,fortgym- Edit
infra/ansible/inventory.iniwith your VM IP/user/key. - Update
infra/ansible/group_vars/all.yml:dfhack_archive_url(Linux DF+DFHack bundle) and optional checksum.service_user/service_group(defaultubuntu).fortgym_repo_url(defaults to this repo) &fortgym_checkout_ref.
- Export
FORT_GYM_ADMIN_PASSWORDbefore running Ansible (admin endpoints are disabled without it). - Run:
make vm-provision make vm-start make vm-status
See CLAUDE.md for the current VM workflow (seed saves, systemd services, HTTPS reverse proxy).
With the service running:
curl -s -X POST http://127.0.0.1:8000/runs \
-H 'Content-Type: application/json' \
-d '{"backend":"dfhack","model":"fake","max_steps":50,"ticks_per_step":500}'Then:
curl -s http://127.0.0.1:8000/runs/<run_id>
curl -N "http://127.0.0.1:8000/runs/<run_id>/events/stream" | head -n 15
ls -l fort_gym/artifacts/<run_id>/
head -n 3 fort_gym/artifacts/<run_id>/trace.jsonl
cat fort_gym/artifacts/<run_id>/summary.json | jq .Note: the interactive
/stepflow is validated against the single-action schema used by thefakeagent. Manager orders issued by the exploratoryrandomagent remain experimental and may be rejected until DFHack execution coverage improves. The SSE endpoint emitsstate,action,validation,execute,advance,metrics, andscoreevents.summary.jsonaccumulates aggregate metrics, including live DFHack work, completion, utility, production, wealth, and visible fortress complexity progress when target-room metrics are available. Basic survival, population, and drink availability remain bounded health checks; fort-growth components are intentionally open-ended, so longer real-gameplay runs can keep gaining points as the fort digs, builds, produces, and creates wealth.
For legal DFHack-command gameplay, use a governed model:
# Deterministic reference agent (validates the action substrate)
curl -s -X POST http://127.0.0.1:8000/runs \
-H 'Content-Type: application/json' \
-d '{"backend":"dfhack","model":"dfhack-governed-scripted","max_steps":30,"ticks_per_step":1000}'
# LLM policy on the same governed action surface (OpenRouter, default z-ai/glm-5.2)
curl -s -X POST http://127.0.0.1:8000/runs \
-H 'Content-Type: application/json' \
-d '{"backend":"dfhack","model":"dfhack-governed-llm","max_steps":30,"ticks_per_step":1000}'Governed runs treat DFHack as a bounded, audited command transport — the agent
issues overseer commands (DIG, BUILD, ORDER, UNSUSPEND, FARM, LABOR,
WAIT, INTERACT) a human could issue
through the UI, then the simulation must advance and produce observable state
changes. Each accepted governed action is tagged execute.provenance = "dfhack_governed", per-step metrics carry score_provenance = "dfhack_governed_observed_state", and every step records a real screen_text
CopyScreen frame for replay evidence. Governed target discovery preserves and
restores the live DF camera/cursor so helper probes never disturb the visible
game. The fort minimap distinguishes stable . floor from seasonal frozen
liquid (i); frozen liquid is excluded from room/site floor counts and every
structured BUILD helper rejects it before mutation. A verified 3x3 workshop
site remains available for either CarpenterWorkshop or Still after earlier
buildings are placed. INTERACT is a zero-tick, paused-dialog action and is never score-progress
eligible by itself. Non-governed models that emit structured governed actions are tagged
dfhack_assisted: their progress metrics are zeroed and — once one assisted
action is accepted — the run's scoreable elapsed time is blocked for the rest
of the run. summary.json additionally includes a deterministic rubric
(8 dimensions over the last 100 trace rows) with explicit blockers such as
illegal_or_assisted_progress_seen and repetitive_policy.
The openrouter-keystroke-perception-review model controls Dwarf Fortress via raw keystrokes through OpenRouter-compatible chat completions. It requires the agent to submit its own screen_read and last_action_review before each keystroke action. By default it uses OPENROUTER_MODEL=z-ai/glm-5.2; openrouter-glm-5.2 pins that model explicitly. (The older anthropic-keystroke* variants use the same machinery but are disabled unless FORT_GYM_ENABLE_ANTHROPIC=1.)
- Screen Observation: The game screen is captured via DFHack's CopyScreen RPC and converted to an 80x25 text representation
- Model Decides: The LLM sees the screen text and decides what keystrokes to send
- Keystroke Execution: Keys are sent via DFHack's
devel/send-keycommand - Game Responds: The game processes the input and advances
curl -s -X POST http://127.0.0.1:8000/runs \
-H 'Content-Type: application/json' \
-d '{"backend":"dfhack","model":"openrouter-keystroke-perception-review","max_steps":10,"ticks_per_step":200}'Common interface keys include:
- Navigation:
CURSOR_UP,CURSOR_DOWN,CURSOR_LEFT,CURSOR_RIGHT,CURSOR_UP_Z,CURSOR_DOWN_Z - Selection:
SELECT,DESELECT,LEAVESCREEN - Main Menus:
D_DESIGNATE,D_BUILDING,D_BUILDJOB,D_STOCKPILES,D_ZONES,D_ORDERS - Designate:
DESIGNATE_DIG,DESIGNATE_CHANNEL,DESIGNATE_STAIR_DOWN,DESIGNATE_CHOP
Full list of 1600+ keys available via: dfhack-run lua "@df.interface_key"
{
"type": "KEYSTROKE",
"params": {"keys": ["D_DESIGNATE", "DESIGNATE_DIG", "CURSOR_DOWN", "SELECT"]},
"intent": "Designating a dig area below current position"
}Generate a leaderboard snapshot for the static site:
python scripts/publish_leaderboard.py
cat web/leaderboard.json | headCopy the example env and fill it in:
cp .env.example .envOPENROUTER_API_KEYis the primary LLM key (OPENROUTER_MODELdefaults toz-ai/glm-5.2).OPENAI_API_KEYis optional for the OpenAI agents.ANTHROPIC_API_KEYalone is not enough for Anthropic agents — the API server rejectsanthropic*models with HTTP 400 unlessFORT_GYM_ENABLE_ANTHROPIC=1is also set. They are legacy and off by default.- DFHack backend runs on Linux; macOS typically uses the mock backend or targets the Linux VM.
| Variable | Description |
|---|---|
dfhack_install_dir |
Path where DF/DFHack is installed (/opt/dfhack). |
dfhack_archive_url |
URL of Linux DFHack bundle to download. |
dfhack_remote_port |
Remote plugin TCP port (default 5000). |
service_user / service_group |
Unix account running services. |
fortgym_repo_url |
Git repository URL (default GitHub). |
fortgym_checkout_ref |
Branch/tag for fort-gym checkout. |
fortgym_install_dir |
Install path (/opt/fort-gym). |
fortgym_venv_dir |
Virtualenv directory (/opt/fort-gym/.venv). |
fortgym_service_enabled |
Enable fort-gym API systemd service (false by default). |
fortgym_service_port |
API bind port (default 8000). |
allow_tcp_ports |
List of TCP ports opened via UFW (e.g. [22, 80, 443]). |
The web interface at https://fortgym.live/ (or local dev at http://127.0.0.1:8000/) provides:
- Fort Labs Overview (
/): gameplay-first homepage with a filmstrip of real public run frames, the active or latest recorded world, protocol-scoped comparisons, evaluation profiles, and the Fort-Eval evidence path. Missing media stays visibly unavailable; the page does not synthesize gameplay. - Recorded Worlds (
/worlds): paginated library of public DFHack runs with replay scope, model, status, protocol, seed, score, and real bounded frame previews. Filters operate on persisted registry metadata without parsing traces during list requests; visible cards load their own bounded previews lazily. - Results (
/results): protocol-scoped comparison groups admitted only when completed DFHack runs carry the full Easy v1 declaration and non-empty replay evidence. Earlier scoring eras remain in the separate historical dashboard. - Protocols (
/protocols,/protocols/{slug}): public definitions for the current Easy pilot and the planned Hard and Discovery profiles. The Easy page is derived from the checked-in protocol manifest; future profiles are explicitly identified as planned research surfaces. - Findings (
/findings): a curated, versioned research note linking each claim and caveat to the corrections log and representative public replays. Gate status is declared in the checked-in findings manifest rather than inferred from scalar scores. - Link previews: every public Fort Labs page publishes canonical Open Graph and large-card metadata. Replay permalinks render run-specific metadata and a
1200x630card from the same bounded recorded frame used by/preview; missing frames remain visibly unavailable, and non-replay shares return404without metadata leakage. - Live Game View (running runs only): polls the public screenshot endpoint (
/public/runs/{token}/screenshot, scopelive) every 500ms and renders the DF screen with pcface CP437 bitmap fonts. This is a genuinely live CopyScreen RPC read of the DF process the server is attached to — it is not per-run isolated, so it is only meaningful while that run is the one executing. - Replay View (finished runs,
/r/{token}): loads the recordedtrace.jsonland steps through it. See "Replay Evidence Boundaries" below for exactly what each replay mode does and does not prove. - Observer Map (inside saved replays): accumulates revealed trace tiles, model-authored action footprints, verified tile changes, and z-levels through the selected step. It is a richer derived spectator view and is never silently passed to the agent.
- Historical Leaderboard: Best score over time per backend/model/version (
/leaderboard). This predates the Fort-Eval protocol manifest and is not an official cross-model cohort. - Live/Replay Streams: SSE event streams (
/public/runs/{token}/events/streamand/events/replay). - Admin Panel (
/admin): Create runs, manage jobs, generate share tokens (basic auth required; disabled ifFORT_GYM_ADMIN_PASSWORDis empty).
fort-gym separates agent input, gameplay proof, and richer spectator evidence. The six surfaces:
| Surface | What it is | Gameplay proof? |
|---|---|---|
Live screenshot (/public/runs/{token}/screenshot) |
Live CopyScreen RPC of the currently attached DF process, while a run executes | Live view only — not recorded, not per-run isolated |
| Replay DF Screen frames | screen_text recorded into trace.jsonl each step (keystroke and governed runs) — real CopyScreen text at that moment of play |
Yes — recorded evidence of the actual game screen |
| Replay Map Inspect | map_snapshot — a derived DFHack tile read (≤64×64 rect) recorded per step. Labeled on-canvas "DERIVED DFHACK MAP INSPECTION / not gameplay proof" |
No — analysis/debugging layer only |
| Replay Observer Map | Accumulated revealed tiles, located action footprints, verified changes, and z-levels through the selected trace step | No — richer spectator-only evidence; not agent input unless a future protocol explicitly allows it |
| No Recorded DF Screen Frame | Shown for old traces recorded before screen_text capture existed. The replay refuses to pass off derived data as a screen |
Honest evidence gap — these runs cannot retroactively prove screen state |
| Score + rubric | composite_score over observed DF state deltas, provenance-gated; deterministic 8-dimension rubric over the last 100 trace rows with blockers |
Scoring judgment over real state — reported alongside, never instead of, the evidence above |
Rules the harness enforces:
screen_textis captured only in keystroke and governed modes. Old traces and toolbox-agent traces have none and display the evidence-gap panel — this is by design, not a bug.- Structured
DIG/BUILD/ORDERfrom non-governed models isdfhack_assisted: progress zeroed, scoreable duration blocked for the rest of the run. - The debug helper
hook/complete_dig_rect.lua(instantly converts designated walls to floors) is never gameplay and never scores.
- Set a non-empty
FORT_GYM_ADMIN_PASSWORDbefore exposing the service. - Keep DFHack RPC on loopback only; do not expose port 5000 directly.
- Serve the API behind HTTPS (Caddy/Nginx) and restrict inbound traffic (GCE firewall/UFW).
- Rotate API keys regularly; never commit
.envwith secrets.
These systems are implemented (not roadmap):
- Memory (
fort_gym/bench/agent/memory.py):MemoryManagerwith a rolling step window, compressed summary of older steps, POIs (points of interest), failed-attempt records, and a gameplay plan with plan reviews. The governed agent can serialize this state throughFORT_GYM_GOVERNED_MEMORY_PATH; production runs currently disable it because the G5 ablation found the present design counterproductive. - Tools (
fort_gym/bench/agent/tools.py):ToolManagerwith memory/plan/perception tools wired into the review-mode agents. - Experimentation (
fort_gym/bench/experiment/): YAML config →ExperimentRunner→ run with experiment metadata.
The best long scored G7 result remains attempt 22, run
e83950a358e745c4ad3f0796e4c9c8bb
(replay),
from deployed SHA 73700c5d588859c8c8ebfd1623895b59e1f87b6b. It retained
156 real governed gameplay rows, all with screen text, gameplay proof, and
governed provenance; 51 proof records had ok=true. The policy completed
vertical access by step 3, lower excavation by step 8, a Carpenter workshop by
step 21 and a Still by step 30. It placed a subterranean FarmPlot at step 42,
observed stage-3 completion at step 45, and selected crops at step 46. Native
work produced 75 drink and five food units. Population reached 15,
three beds were installed, and the final fort had 56 constructions, two
enclosed spaces, and two functional rooms. Score-v5 remained action-owned.
The run failed at step 155 after 200,252 trace ticks. Final flow was food 5
produced/36 consumed and drink 75/81, with stocks 15/54. Deterministic G7
gate-v2 passed evidence, population, and zero-neglect death evidence; duration,
resource loops, three rooms, five beds, rubric 59.38 with
no_broader_fort_layout, and score-v5 93.31/150 failed. The terminal reason was
tick_timeout_zero_progress: a liaison sequence left the game on
viewscreen_storesst, which the observation called unknown and the legal
INTERACT allowlist could not exit. The model then submitted BUILD from that
blocking screen and requested 1,200 ticks; zero advanced. The next candidate
added only the missing legal INTERACT cancel, factual blocking-screen state,
and pre-execution rejection of any other Stores-screen action. PRs #94 and #95
merged and deployed at 506ce6986029c5885ecb26074fa45ac55d47c541.
A live proof sent one LEAVESCREEN, advanced zero ticks, and moved from
viewscreen_storesst to viewscreen_topicmeeting_takerequestsst.
Fresh-seed Attempt 23
(replay)
then completed real channel access, 43 owned excavations, a subterranean
FarmPlot, a Carpenter workshop, a surface Still, two functional rooms, and one
bed. It was operator-stopped after 125 steps when drink reached zero and one
dwarf died. Deterministic G7 failed at 185,487/403,200 trace ticks: food flow
3/31, drink flow 0/60, population 10/15, rooms 2/3, beds 1/4, rubric 65.97 with
no_broader_fort_layout, and score-v5 115.39/150. PRs #97 and #98 now return
the complete rejected 3x3 workshop footprint and expose cached native job-target
walk-group connectivity; neither chooses a repair or target.
Attempt 24
(replay)
was an infrastructure abort at step 0, not a policy result. Its first tick read
after enabling DFHack nopause timed out; the old early return bypassed repause
and terminal cleanup trusted command completion without reading the actual pause
state. The fail-closed controller now places every unpaused exit under cleanup,
disables nopause, attests pause_state=true, rejects unverified cleanup, and
performs the same attestation before the first agent turn. A fresh replacement
run followed deployment of PRs #99 and #100 at
005fdccd79993b1dd3a0451eae43a61fdc1a9dd3. A live 10-tick probe reported the
same stable post-pause tick as an independent read: 58,140 to 58,161, with
pause_state=true on two reads.
Attempt 25
(replay)
then retained 72 real governed gameplay rows on that exact SHA. It completed two
channel accesses, eight owned excavation completions, one usable Carpenter
workshop, and five beds in stock. It installed no furniture, completed no room,
and produced no food or drink. Deterministic G7 failed at 86,062/403,200 ticks,
with population 7/15, rooms 0/3, final food/drink 44/33, rubric 54.1 with
no_broader_fort_layout, and score-v5 82.1/150. Before step 72 gameplay, three
model responses repeated the same objective above the 160-character schema cap;
the run failed closed. The trace also showed repeated gather attempts against
native ShrubDead tiles, which cannot produce gather jobs. PR #101 deployed a
schema boundary that normalizes only identical duplicated overlong objectives
and returns explicit
dead_shrub_ungatherable / no_gatherable_shrubs feedback without choosing a
strategy or target. On the paused terminal save, a known dead shrub returned
that exact rejection while tick 102,863, pause state, tile type, and designation
remained unchanged. Fresh-seed Attempt 26 ran on the exact merge SHA
a8f696ac17a4a8205306b327a284f78ea74ae9bf
(replay) and was
operator-stopped after 150 governed gameplay rows. It completed a Carpenter
workshop, Still, connected channel, 3x3 subterranean FarmPlot with plump
helmets selected for every season, one installed bed, and 25 drink production;
all nine dwarves survived. Deterministic G7 still failed at 206,901/403,200
ticks, food 0 produced/38 consumed, drink 25/71, population 9/15, one installed
bed, rubric 69.91 with no_broader_fort_layout, and score-v5 117.07/150. The
deployed room read was also false: its sole "bedroom" was a sealed one-tile
bed-and-door pocket. The fail-closed candidate reports zero rooms on that save
and rejects a zero-target chop over the real farm without changing tick, pause,
or any designation.
Attempt 27, run 4eded56b1d224fbab47d336df7c521f6
(replay), is an
OPERATOR-STOPPED POLICY-DIAGNOSTIC FAIL, not a G7 success. It retained 225
real governed gameplay rows with screen text and governed provenance, using only
OpenRouter z-ai/glm-5v-turbo (no Anthropic model). Legal DFHack controls built
a connected channel/ramp and lower excavation, Carpenter workshop and Still, six
installed beds, three functional rooms, and a genuinely subterranean plump-helmet
farm that received and completed a native PlantSeeds job; population reached 17.
The operator stopped at step 225 after preserving artifacts because an
active-ledger citizenship bug falsely admitted same-civ merchants, irreparably
contaminating death and consumption evidence. Deterministic G7-v3 still fails:
246,752/403,200 ticks, food 6/44 and drink 75/113 produced/consumed, rubric
67.39 with no_broader_fort_layout, and score 142.47/150. The scalar is not a
success verdict. PRs #109-#111 merged the raw farm evidence, governed
completion/stall, and own-group citizen-evidence corrections; the next action is
to deploy the reviewed contract plus a labor-eligibility correction, then launch
a fresh Attempt 28.
G7 remains open. Full findings and gate predicates are recorded in
docs/WDSLL.md.
Success definition and gate ladder: docs/WDSLL.md — every claim of "the agent plays" must pass a gate there on public, replayable evidence.
- DFHack service won't start: check
/var/log/syslogandjournalctl -u dfhack-headless. Verifydfhack_archive_urlpoints to a Linux build. - Remote not listening: ensure the remote plugin is enabled; run
ss -lntp | grep 5000. - SSE shows no events: inspect API logs (
journalctl -u fort-gym-api -fon the VM) and confirm the share token haslivescope. - Jobs stall: query
/runs/<id>to check progress, tailjournalctl -u fort-gym-api -f, and ensure DFHack is responsive (/opt/dwarf-fortress/dfhack-run lua 'print(dfhack.getSavePath())'). - Missing DFHack protobuf bindings: the
make protohelper expects protos infort_gym/bench/env/remote_proto/sources. If upstream URLs move, copy the.protofiles out of/opt/dfhack-srcand regenerate with the commands listed above. - LLM invalid actions: adjust prompts or ACTION_TOOL_SPEC usage; ensure the tool call returns a single action dict.