# NetHack MCP server and spectator UI
Status: Implemented.
## 1. Purpose and decisions
Build a program through which an LLM agent can play NetHack using MCP,
while a person watches the same game in a read-only UI. The application
owns the game session, but does not call an LLM itself. An external MCP
client supplies the agent and decides when to invoke tools.
Confirmed requirements:
- All new server and engine integration code uses C++23. NetHack remains
C. Use libmw where it provides suitable utilities.
- The UI is read-only. It has no gameplay inputs, reset button, or manual
takeover mode.
- ASCII graphics are sufficient.
- Fetch NetHack, libmw, and other project dependencies through CMake
FetchContent with pinned revisions. Build Lua 5.4.9 through FetchContent;
common platform libraries such as curl and OpenSSL may come from the system.
- This document specifies the design only; it does not authorize
implementation.
Proposed defaults:
- A browser spectator page is the first UI. A terminal spectator can be
added later using the same observation endpoint.
- One active game per MCP server process; multiple spectators may watch.
- MCP uses stdio. A separate loopback HTTP listener serves the viewer.
- A single executable has a server mode and an internal engine-worker
mode. These run as separate processes.
- Tools expose individual keys and structured prompt/menu responses.
Semantic commands such as `move(north)` are deferred.
- Each game uses a private working directory. Normal NetHack rules apply;
hidden engine state is not exposed to the agent.
Non-goals for the first version include multiple simultaneous games in
one server, remote hosting, an autonomous agent loop, tiles, replay
playback, arbitrary NetHack configuration, and high-level navigation.
## 2. Evidence and integration constraints
The inspected reference checkout is `NetHack/`, branch `NetHack-5.0`,
commit `c94fd5225beef48143244bfb7bc42682aad58741`. It was used for
research only. The application build must fetch NetHack through
FetchContent and must not require this local directory.
The relevant local source files are:
| Source | What it establishes |
| --- | --- |
| [libnh README](../NetHack/sys/libnh/README.md) | Library entry point and shim callback registration |
| [shim implementation](../NetHack/win/shim/winshim.c) | Actual callback names, arguments, and return types |
| [window types](../NetHack/include/wintype.h) | Glyph and menu identifier layouts |
| [window interface](../NetHack/include/winprocs.h) | Typed window-port functions |
| [window specification](../NetHack/doc/window.txt) | Semantics of drawing, menus, prompts, and status |
| [role selection](../NetHack/src/role.c) | Generic character selection through window callbacks |
| [termination](../NetHack/include/extern.h) | Unix `nethack_exit` maps to process `exit` |
| [library startup](../NetHack/sys/libnh/libnhmain.c) | Startup, working-directory changes, and game loop |
The shim is a UI interface, not a turn-based engine API. NetHack calls
into the port to draw and request input. Input callbacks may occur during
character creation, normal commands, direction selection, menus, or
end-of-game disclosure. A request for a key does not necessarily mean a
new game turn has started.
Some input is already structured: text prompts supply a buffer, menus
return selected identifiers, and extended commands return an index into
the command table. We should preserve these distinctions.
The NetHack library build uses the Lua headers and library fetched through
CMake. The build adapter supplies the versioned archive marker expected by
NetHack's Linux library rule, then links the separately built Lua library
into the server. This avoids relying on the host distribution's Lua package
and avoids nesting a Lua archive inside the NetHack archive.
The [official upstream library documentation](https://github.com/NetHack/NetHack/blob/NetHack-5.0/sys/libnh/README.md)
is a useful overview. Exact ABI details must come from the pinned fetched
headers: for example, the current glyph layout nests color information
inside `glyph_info.gm`, rather than matching older examples verbatim.
## 3. Process architecture
```mermaid
flowchart LR
Agent[External LLM / MCP client] <-->|MCP stdio| Server[C++ server]
Server <-->|Private framed JSON channel| Worker[C++ engine worker]
Worker <-->|Window callbacks| NH[NetHack static library]
Browser[Read-only browser] -->|GET snapshots| Server
```
The parent owns MCP, HTTP, worker supervision, and immutable observations.
The worker owns every call into NetHack and all NetHack global state.
This separation is necessary because normal NetHack termination calls
`exit()`. Running the game on a parent-process thread would still exit
the entire server when the game finishes.
Launch the same executable with an internal `--engine` argument using
`posix_spawn` and descriptor actions. Do not fork and then run C++ game
code inside an already multithreaded parent. Pass a private socket or pipe
pair explicitly. The worker must never inherit the MCP stdout descriptor
as its ordinary stdout. Redirect ordinary worker output to a diagnostic
pipe and send only protocol frames over the dedicated IPC descriptor.
The parent continues serving the final observation after worker exit.
Starting another game launches a fresh worker, avoiding assumptions about
whether NetHack globals can be reinitialized safely.
### Responsibilities
| Component | Responsibility |
| --- | --- |
| `McpServer` | Protocol handshake, tools, validation, response serialization |
| `GameSession` | Session identity, operation state, input eligibility |
| `EngineProcess` | Spawn, framed IPC, exit status, termination and reaping |
| `WindowAdapter` | Translate actual NetHack callbacks into owned values |
| `ObservationStore` | Publish consistent snapshots and bounded history |
| `SpectatorServer` | Serve static assets and GET-only observations |
Use one coordinator to serialize state mutations. The MCP reader must
remain responsive while an engine action is pending. HTTP handlers read
published snapshots; they never invoke NetHack. No snapshot lock may be
held during engine I/O or while waiting for user input.
## 4. Window adapter and C boundary
Prefer the shipped shim as the initial integration point. Register one
callback, dispatch by its name, and immediately copy pointer arguments
before returning. Read variadic arguments using their actual C types and
default promotions, not merely the shim's format string. Some format
strings are insufficient to reconstruct the native type accurately.
Keep NetHack headers confined to an adapter translation unit. Include C
declarations with C linkage, and compile against the same configuration
as the static library. If the headers cannot compile as C++, use a small
C ABI adapter for those declarations; this is an integration fallback,
not a second server implementation. Never reproduce NetHack structs by
hand to avoid including headers.
No C++ exception may cross a C callback frame. Catch at the callback
boundary, emit a fatal diagnostic when possible, and terminate the worker
with an error status.
### Callback coverage
| Callback family | Required behavior |
| --- | --- |
| Initialize / create / destroy | Allocate window IDs, track types and lifetimes, set required initialization state |
| Map glyph / clear / cursor | Update cells and cursor using supplied glyph data |
| String / mixed string | Retain messages or window text; decode glyph escapes through NetHack helpers |
| Menu start / add / end | Build menu entries, copying opaque identifiers inside the worker |
| Menu select | Publish a menu request and wait for structured selections |
| Character / position input | Publish a key request; return exactly one validated key |
| Yes/no | Publish question, allowed choices, and default; implement documented escape/default behavior |
| Line input | Copy bounded text into the caller's buffer; cancellation returns NetHack's escape sentinel |
| Extended command | Present available command names and resolve a response to its actual table index |
| Player selection | Use the generic NetHack selection path through the same menus and prompts |
| File display | Read through NetHack's data-library facilities, then expose text and acknowledgement |
| Status | Track field activation, values, condition bits, flush, and reset |
| History | Maintain bounded message history and honor restore/history callbacks |
| Inventory update | Mark cached inventory stale unless a verified refresh path updates it |
| Exit | Publish final available UI state before normal worker shutdown |
Callbacks intentionally implemented as no-ops, such as an optional bell,
must be listed explicitly. Unknown input callbacks are fatal integration
errors rather than silently returning zero.
The shipped shim uses some generic status handlers instead of forwarding
every field-enable callback. During implementation, verify this wiring
and use a narrow adapter override where necessary. Do not advertise a
status or permanent-inventory capability that the adapter cannot honor.
Menu identifiers can contain pointers. They remain private to the worker.
An external entry ID is an integer assigned for that menu instance; the
worker maps it back to the copied `anything` value. Allocate returned
`menu_item` arrays using allocation compatible with NetHack's `free()`.
## 5. Input synchronization
Every blocking input callback creates an input boundary. The worker first
flushes pending display updates, then publishes a snapshot containing a
new `input_id`, and blocks on the IPC channel. IDs increase throughout a
game and never repeat within that game.
Every gameplay response carries `game_id` and `input_id`. The parent
rejects responses for an old game or an old prompt before forwarding
anything. This protects against delayed responses and duplicate calls.
The first version accepts one key per `press` call. It does not keep a
general key queue. For example, opening a door is:
1. Agent presses `o` at input boundary 40.
2. NetHack requests a direction at boundary 41.
3. Agent presses `h` at boundary 41.
4. NetHack resolves the action and reaches boundary 42.
The next boundary may be another prompt, not normal gameplay. Return it
as observed. This also supports command prefixes and count entry without
guessing when a sequence is safe to consume.
`nh_poskey` and `nhgetch` identify the callback but cannot reliably prove
that a request is a normal movement command. Use pending kind `key` with
a `source` field. Do not invent a reliable `normal_turn` classification.
Text, yes/no, menus, extended commands, and acknowledgements have distinct
pending kinds. `press` is rejected for those kinds; `respond` or
`select_menu` is used instead. Informational menus and blocking text
windows require explicit acknowledgement. Merely viewing them in the
browser must never acknowledge them.
### Operation completion and waiting
An accepted input is applied once. The operation completes when the next
input boundary arrives or the worker exits. A 10-second response deadline
limits how long an MCP call waits; it is not a game-turn limit.
If the deadline expires, return `operation_state: running` with an
`operation_id` and the latest snapshot. The agent must use `observe` to
check completion. Reject further gameplay actions as `BUSY` until the
operation completes. Never resend input because a wait timed out.
Cancellation stops waiting for an MCP result; it cannot undo consumed
game input. Preserve the operation and its eventual result in session
state. A canceled request must not trigger a replacement key.
## 6. Observations and fairness
Observations describe what a conventional UI receives. Do not expose
unseen monsters, true identities of unidentified objects, hidden map
terrain, future RNG values, or raw engine memory.
The canonical snapshot has the following shape. Values here are
illustrative; `map.rows` in a real snapshot contains 21 complete rows.
```json
{
"schema_version": 1,
"game_id": "g_opaque_unique_id",
"revision": 17,
"lifecycle": "waiting",
"operation": null,
"map": {
"width": 79,
"height": 21,
"origin": {"x": 1, "y": 0},
"rows": [],
"cursor": {"x": 38, "y": 10}
},
"status": {
"hp": {"text": "12", "value": 12},
"max_hp": {"text": "12", "value": 12}
},
"messages": [{"id": 8, "text": "Hello Agent!"}],
"messages_truncated": false,
"inventory": {"known": false, "stale": true, "entries": []},
"pending": {"input_id": 5, "kind": "key", "source": "nh_poskey"}
}
```
NetHack's display columns are 1 through 79; column 0 is internal.
Rows are 0 through 20. Preserve those coordinates in tool responses and
show coordinate rulers in the UI. Cursor position is not necessarily
player position: inspection and targeting may move it.
Maintain glyph IDs and colors internally from rendered callbacks. Use
ASCII rows for compact tool output; offer visible cell details through
`observe(detail: full)`. Do not derive object identities by consulting
hidden structures behind a glyph. Clear the cached map on the port's map
clear callback so old levels cannot bleed into new displays.
Preserve formatted status text even when a numeric interpretation exists.
Normalize only fields with verified types; hunger and conditions require
their own representation. A missing or disabled field is absent, not
zero. Turn count, if exposed, comes from the displayed time field; a
snapshot revision is never treated as a game turn.
For gold, remove NetHack's encoded currency glyph and colon so both the
text and numeric value contain only the amount.
Store up to ten recent messages with monotonically increasing message
IDs. `observe(after_message_id)` can return only newer messages and must
report truncation if the cursor predates the retained buffer. Ordinary
tool results include a bounded recent tail, so repeated observations do
not grow indefinitely.
Inventory is not guaranteed to arrive as a complete independent stream.
Initially, expose inventory windows when the agent requests them through
the normal `i` command. Cache entries only when the adapter can identify
a full inventory response reliably; otherwise leave `known: false` and
show the menu itself. Mark any cached inventory stale after gameplay
input. `observe` never secretly executes an inventory command.
Preserve final menus and text in the published snapshot until a subsequent
input boundary supersedes them. Destruction of a NetHack window should
not erase information before an agent or spectator can observe it.
## 7. MCP contract
Use the published MCP `2025-11-25` protocol as the initial supported
version. During initialization, negotiate that supported version rather
than blindly echoing the client's requested version. Advertise only the
tools capability. Implement `initialize`, `notifications/initialized`,
`ping`, `tools/list`, `tools/call`, and cancellation handling. Follow the
[MCP lifecycle specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle).
Use newline-delimited UTF-8 JSON-RPC on stdio. Only protocol messages go
to stdout; diagnostics go to stderr. Serialize writes to prevent threads
from interleaving responses. Enforce a 1 MiB incoming message limit.
See the [stdio transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports).
Every tool has a JSON Schema with `additionalProperties: false`, explicit
required fields, and bounded values. Return an object as
`structuredContent` and a text content block containing its JSON encoding.
Tool execution failures use `isError: true`; malformed protocol envelopes
and unknown methods use JSON-RPC errors. See the
[MCP tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
### Tool definitions
| Tool | Arguments | Behavior |
| --- | --- | --- |
| `new_game` | Optional `name`, `role`, `race`, `gender`, `alignment` | Start a fresh worker and return its first boundary |
| `observe` | Optional `game_id`, `detail`, `after_message_id`, `wait_ms` | Read current state; optionally wait for a revision or operation completion |
| `press` | Required `game_id`, `input_id`, `key` | Answer one pending key request |
| `select_menu` | Required `game_id`, `input_id`, `selections`; optional `cancel` | Return selected menu entries or cancellation |
| `respond` | Required `game_id`, `input_id`; response fields depend on pending kind | Answer text, choice, command, or acknowledgement |
| `quit_game` | Required `game_id` | Stop the current worker and retain its last state |
`new_game` defaults to name `Agent` and random unspecified character
attributes. Validate values against NetHack's supported choices. Reject
new-game requests while a game is active; require explicit termination
first. Names are display names, never directory paths. Character
selection uses NetHack's own compatibility checks and can surface menus
if needed. Do not silently claim that an incompatible requested character
was created exactly as specified.
`observe.detail` is `compact` by default or `full`. `wait_ms` ranges from
0 to 10000; default 0. Observing before a game starts returns `idle` and
the viewer URL. An explicitly wrong game ID returns `STALE_GAME`.
`press.key` accepts a single printable ASCII character, or a named key
`ENTER`, `ESC`, `SPACE`, `TAB`, `BACKSPACE`, `CTRL_A` through `CTRL_Z`, or
`META_x` for a single printable ASCII suffix. Specify and test the byte
mapping centrally. Do not use terminal escape sequences to encode arrows;
the agent can use NetHack movement keys. Zero bytes are forbidden.
`select_menu.selections` is an array of `{entry_id, count}` objects.
`count` is omitted for all items in that entry or is a positive integer
within NetHack's supported range. Reject duplicate IDs, nonselectable
entries, and multiple selections for `PICK_ONE`. `PICK_NONE` requires an
empty selection list and acknowledges the menu. An empty list for a
selectable menu means select nothing; `cancel: true` means cancellation.
Expose preselected flags in observations; submitted selections are the
complete desired result, so the agent need not emulate toggle keys.
`respond` accepts exactly one response variant:
- `text`: line content without newline, bounded to the pending capacity.
- `choice`: a permitted single character for a choice prompt.
- `command`: an exact offered extended-command name.
- `acknowledge: true`: continue past blocking informational text.
- `cancel: true`: use the pending kind's documented cancellation behavior.
The pending object reports accepted variants, choices/default, text byte
capacity, or menu mode as appropriate. Invalid responses leave the worker
blocked at the same input ID. Text limits are byte limits, not Unicode
character counts. Reject unsupported encoding explicitly.
`quit_game` is administrative termination, not a claim that NetHack's
in-game quit dialogue completed. It terminates the worker, reaps it, and
labels the session `aborted`. An agent wanting normal in-game quitting
can use the extended `quit` command and answer its prompts. This
distinction permits recovery from a stuck game without manufacturing a
normal game result. Repeated administrative termination of the same
terminal session is harmless.
Useful application error codes are `NO_GAME`, `GAME_ACTIVE`, `STALE_GAME`,
`STALE_INPUT`, `WRONG_INPUT_KIND`, `INVALID_SELECTION`, `INVALID_RESPONSE`,
`BUSY`, `ENGINE_FAILURE`, and `LIMIT_EXCEEDED`. Errors include current
game/input IDs when available so the agent can recover by observing.
## 8. Worker protocol and supervision
IPC uses a four-byte unsigned big-endian payload length followed by that
many UTF-8 JSON bytes. Set a 4 MiB frame limit in both directions. Read and
write loops handle partial transfers and interrupted system calls.
Reject malformed, oversized, out-of-order, or unsupported frames.
The worker first sends `hello` with an IPC version and NetHack build
identity. Parent and worker must agree on IPC version 1. Each frame also
carries the game ID; worker events have an increasing sequence number.
Parent messages are `start` and `input`. `start` contains only validated
character settings and the private run path. `input` carries the pending
input ID and the already validated typed response. The worker validates
again because it owns the authoritative pending callback.
Worker events are `snapshot`, `diagnostic`, and `exiting`. A snapshot
contains the complete visible state at an input boundary. Optional
intermediate snapshots can be coalesced to at most 10 per second during
long actions; boundaries and final snapshots must not be dropped.
Parent supervision treats process exit as authoritative. Drain remaining
IPC frames before publishing the terminal state. A clean exit after
normal shutdown is `ended`, an intentional parent stop is `aborted`, and
an unexpected nonzero exit or signal is `failed`. Do not infer victory
from exit code zero. Preserve rendered end-of-game text; a structured
victory/death classification can be added only after a reliable source
has been verified.
Shutdown sequence on MCP stdin EOF or parent termination:
1. Stop accepting gameplay calls and close the IPC input channel.
2. If the worker remains alive, send SIGTERM and wait up to two seconds.
3. Send SIGKILL if necessary and reap it.
4. Stop HTTP and join parent threads.
Worker IPC EOF must end the worker rather than returning endless escape
keys to NetHack. Enforce a bounded diagnostic buffer, and keep draining it
so excessive output cannot deadlock the engine.
## 9. Browser spectator
Serve a small bundled HTML/CSS/JavaScript page with libmw's HTTP facilities.
No frontend framework, Bootstrap, external font, CDN, or tileset is needed.
The C++ executable remains the only server runtime; browser JavaScript is
limited to fetching and rendering observations.
Layout:
- Game name, lifecycle, and last-update indicator.
- Monospace map with coordinate rulers; optional cell colors.
- Status fields, including HP, hunger, conditions, and displayed turn.
- Current pending question, text window, or menu.
- Recent messages and clearly labeled cached inventory, if known.
The page has no gameplay event handlers. Display preferences such as font
size may remain local to the browser. Loading, reloading, disconnecting,
or opening additional tabs has no effect on the game.
HTTP routes:
| Route | Result |
| --- | --- |
| `GET /` | Spectator page |
| `GET /viewer.js`, `GET /viewer.css` | Bundled static assets |
| `GET /api/state` | Latest complete published snapshot |
| `GET /health` | Server readiness and worker lifecycle |
Poll state every 250 ms while visible, with one outstanding request per
tab. Back off when hidden or disconnected. Use revision-based ETags and
`If-None-Match`; unchanged state returns 304. Snapshot responses require
revalidation. Never implement gameplay POST routes or an HTTP MCP endpoint
in this version.
Bind to `127.0.0.1` by default, with a configurable address and port; default
port 8765. If occupied,
fail promptly with a useful error or allow an explicitly requested port
0 to choose a free port. Report the bound URL on stderr and in tool
results. Do not automatically launch a browser.
Render game text using DOM text nodes or `textContent`, never `innerHTML`.
Do not enable cross-origin access. For public deployment, the reverse proxy
routes the intended public host to the configured listener address. The
application does not inspect Host or Origin headers.
The inspected libmw `HTTPServer::start()` spins until its listener is
running, which may hang on bind failure. During implementation, use an
appropriate supported binding path or make a separately reviewed libmw
fix. A preflight port check alone does not eliminate the bind race.
## 10. Build, files, and persistence
Proposed application layout:
```text
nethack/
CMakeLists.txt
README.md
designs/design-0-mcp.md
src/
main.cpp
mcp_server.cpp
game_session.cpp
engine_process.cpp
window_adapter.cpp
observation_store.cpp
spectator_server.cpp
include/
... corresponding public headers ...
web/
index.html
viewer.js
viewer.css
tests/
build/ generated; includes fetched dependency sources
```
Use CMake for application code and FetchContent for external project
sources, including NetHack and libmw. Fetch from their upstream
repositories using full commit IDs, or versioned archives with verified
hashes. Do not use moving branches as dependency pins or require sibling
development checkouts. The inspected NetHack commit above is the initial
candidate pin.
FetchContent obtains NetHack's sources; it does not replace NetHack's
build system. Connect the populated source directory to CMake custom
build targets that run upstream setup and make, then expose the resulting
static library as an imported target. Build an isolated copy under the
application build directory because upstream setup writes generated
makefiles and headers into its source tree. Build all compilation steps
with `-j24`. Track generated headers and runtime data as build outputs,
and record the upstream commit, compiler, configuration, and Lua version.
Fetch libmw through FetchContent as well, with its build outputs inside
this application's build tree. Apply the same policy to transitive
project dependencies such as cpp-httplib, spdlog, nlohmann/json, and any
test libraries. Declare their pinned FetchContent details before loading
libmw so its moving-branch declarations do not determine the versions.
Do not depend on artifacts from another project's `build/` directory.
Common platform libraries, including curl, OpenSSL, and threads, may be
discovered through CMake package discovery or pkg-config. Keep Lua source
fetching, its SHA-256 pin, and its static target in the CMake build. Pass the
fetched header directory and built library path into the NetHack build
adapter explicitly; do not rely on upstream distribution detection.
Validate the worker against the generated `libnh.a` and the same Lua build.
A passing header compilation alone is not evidence that the library can
start and run a game.
Use a private application data root, configurable on the command line.
Each game gets a unique directory and a `save/` subdirectory with owner-only
permissions. Populate the runtime data required by the pinned NetHack
build, including its data archive, system configuration, and writable
record/lock files. Resolve all asset paths before NetHack changes its
working directory. Do not inherit the user's `.nethackrc` or arbitrary
`NETHACKOPTIONS`; supply controlled defaults with ASCII symbols, visible
turn count, and predictable key bindings.
Retain run directories after termination for diagnostics and possible
recovery. There is no automatic recursive cleanup. Normal save commands
may produce NetHack save files, but resuming them through MCP is deferred
until compatibility and lock recovery are explicitly designed. Do not
promise that stopping the MCP server saves the game.
## 11. Verification and acceptance criteria
Tests must exercise real input transitions, not just JSON formatting.
### Adapter and session tests
- Map origin, width, row count, clear behavior, and cursor distinction.
- Glyph/color extraction against the pinned headers.
- Menu IDs containing both integer and pointer representations; selectable
headers, preselection, counts, empty choice, and cancellation.
- Line limits, invalid choice, default/escape mapping, and extended names.
- Status flush/reset and disabled fields; inventory freshness labels.
- Stale game/input IDs and duplicate requests cannot apply a second input.
- Timeout/cancellation after accepted input cannot cause a replay.
- Malformed frames, partial reads, worker EOF, crash, and oversized output.
### End-to-end tests
1. Complete an MCP initialization and enumerate tools using an independent
MCP client implementation.
2. Start a normal game and receive a nonempty ASCII map and pending input.
3. Make a move or wait action and verify a subsequent boundary arrives.
4. Open inventory, select or acknowledge as appropriate, and return to play.
5. Exercise a direction request and a text prompt; verify neither consumes
unsolicited queued input.
6. End a game normally and verify MCP and HTTP remain alive.
7. Start a second game and verify it has a new worker, directory, and IDs.
8. Kill a test worker and verify a failure snapshot remains inspectable.
9. Close MCP stdin and verify no worker is orphaned.
10. Fetch the viewer from two tabs; verify GET requests do not advance
input IDs, turn count, or game state.
11. Occupy the HTTP port and verify startup fails promptly.
12. Render game strings containing HTML markup and verify they remain text.
Use temporary private run directories for tests. Deterministic callback
fixtures test precise branches; real-game tests must tolerate random map
generation and character outcomes. Test-only wizard scenarios, if used,
must remain isolated from normal game defaults.
Completion means an external MCP client can start, play, handle prompts
and menus, and end a real game while a browser observes the same state.
It also means a normal game exit cannot terminate the MCP server, and no
browser route can change game state.
## 12. Implementation sequence and remaining decisions
After design approval, implement in this order:
1. Reproducible NetHack library/data build and minimal worker startup.
2. Callback adapter, observations, and typed IPC input boundaries.
3. Parent supervision, timeout behavior, and terminal-state retention.
4. MCP lifecycle and tools with independent-client verification.
5. Read-only browser and end-to-end validation.
6. Build/run documentation and MCP client configuration example.
The browser choice, stdio transport, one-key input granularity, and deferred
save/resume support are proposed scope choices rather than explicit user
requirements. They can be revised before implementation. Read-only UI and
C++ application code are established requirements.
Build feasibility, adapter header compatibility, libmw bind-failure
handling, and exact status/inventory callback behavior remain technical
validation items. They should be resolved in the first integration steps
without weakening the input-boundary or read-only guarantees.