# Public multi-game NetHack service
Status: proposed design. This document describes a future implementation;
the current server still owns one game and listens on loopback.
## 1. Purpose and scope
Run one public HTTPS service that allows multiple MCP agents to play separate
NetHack games at the same time. Visitors can watch a game without controlling
it. The home page introduces the service and lists the ten most recently
completed games. SQLite keeps compact game records after runtime directories
and worker processes are removed.
This design supersedes the single-game and local-only deployment decisions
in [design 0](design-0-mcp.md). It preserves that document's worker-process
boundary, NetHack window adapter, input-boundary protocol, and read-only
spectator model.
The first deployment is one application process on one Linux host. A reverse
proxy terminates TLS and forwards requests to the application on loopback.
There is no distributed game manager, cross-host worker migration, replay
archive, or persistent recovery of a running NetHack game in this phase.
The externally visible URLs are:
| URL | Purpose |
| --- | --- |
| `https://my.domain/` | Introduction and ten recent completed records |
| `https://my.domain/g/<game-id>` | Live spectator; completed games redirect to `/` |
| `https://my.domain/mcp` | Streamable HTTP MCP endpoint |
`my.domain` is a deployment example, not a value compiled into the binary.
Configure a canonical public base URL, and generate absolute viewer URLs from
it. A deployment must use HTTPS for public MCP traffic and game-control
secrets.
## 2. Existing code and required changes
`main.cpp` currently creates one `GameSession` and one `McpServer`. A
`GameSession` owns one `EngineProcess`, one `ObservationStore`, and the locks
that serialize its inputs. `GameHttpServer` serves a single `/api/state`
endpoint on a loopback listener. `new_game` already returns a `game_id`, but
`makeGameId()` builds it from a clock and counter. The
`observe` tool currently accepts calls without a game ID. These behaviors
must change before public multi-game use.
One active game still runs in one engine worker process. NetHack's process
globals, environment, working directory, and exit behavior remain isolated
there. The parent server shares its HTTP listener and static assets across
games. Its game manager owns a registry of active `GameSession` objects.
```mermaid
flowchart LR
Agent[MCP agents] -->|HTTPS /mcp| Proxy[TLS reverse proxy]
Viewer[Spectators] -->|HTTPS / and /g/game-id| Proxy
Proxy --> Server[HTTP and MCP server]
Server --> Manager[Game manager]
Manager --> A[Game session A] --> WA[NetHack worker A]
Manager --> B[Game session B] --> WB[NetHack worker B]
Manager --> DB[(SQLite records)]
```
The registry lock protects only lookup, insertion, and removal. It must not
remain held while spawning a worker, waiting for NetHack, or reading SQLite.
Once a request obtains a session handle, that
handle keeps the session alive until the request completes, even if the
registry removes the game. Cleanup marks a game as closing before removing
it, so a concurrent request cannot send input to a worker being terminated.
An implementation may use reference-counted handles here because request
lifetimes outlive registry membership; ordinary single-owner fields within
each session should keep unique ownership.
## 3. Identity and control
`new_game` mints a canonical lowercase UUID version 7 as `game_id`. Follow
[RFC 9562, section 5.7](https://www.rfc-editor.org/rfc/rfc9562.html#section-5.7):
encode the current Unix time in milliseconds in the first 48 bits, set the
version and variant bits, and obtain the remaining random bits from the OS
cryptographic random source. Do not use a process counter or `rand()`.
Reject an invalid UUID, incorrect version, or noncanonical path spelling at
the HTTP boundary. Treat a collision on insertion as a failed attempt and
generate another ID. The database primary key is a final collision guard.
The UUID identifies a game and appears in public URLs, snapshots, and
records. It is **not** permission to control that game. UUIDv7 exposes its
creation time, and the spectator page exposes the complete ID.
For anonymous creation, `new_game` also mints an independent 256-bit random
`control_token`. The response gives the token to the creating MCP client
once. Subsequent game-specific MCP tool calls require both `game_id` and
`control_token`, including `observe` and `quit_game`. Compare a keyed or
salted hash stored in memory, rather than retaining the plaintext token.
Never put the token in the viewer URL, public snapshot, SQLite record,
server log, or HTTP error body. If a game expires or the server restarts,
the token expires with it. A later authenticated-account design can replace
this bearer capability without changing the public game ID.
The token is exposed to the creating agent through the MCP tool result and
passed back as a tool argument. That is an explicit limitation: the MCP
client may record tool arguments in its own transcript. Users should treat
the token like a temporary game password. The viewer endpoints need no
token and remain read-only.
The older MCP transport's optional `MCP-Session-Id` is a client-server
transport session assigned during initialization, not a game ID assigned
by `new_game`. The existing server operates without transport session IDs;
explicit game IDs allow one MCP client to address multiple games and allow
an agent to resume a game after reconnecting. MCP revision `2026-07-28`
removed transport sessions and recommends explicit tool-level handles for
state across calls. Keep the game ID independent of transport version.
See the [2025-11-25 session rules](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management)
and the [2026-07-28 specification announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28/#no-handshake-or-sessions).
## 4. MCP contract
The `/mcp` endpoint remains shared. `initialize`, `ping`, and `tools/list`
do not select a game. Game-specific calls select a session using arguments:
| Tool | Required identity | Result |
| --- | --- | --- |
| `new_game` | None | New ID, one-time token, viewer URL, first observation |
| `observe` | ID and token | Current observation, optionally after a wait |
| `press` | ID, token, input ID | Resulting observation or running operation |
| `select_menu` | ID, token, input ID | Resulting observation |
| `respond` | ID, token, input ID | Resulting observation |
| `quit_game` | ID and token | Terminal observation |
Retain current gameplay argument shapes and add the required token. Do not
put the token inside the ordinary observation object. `new_game` should
return an envelope such as:
```json
{
"game_id": "0199aeb4-6c00-7e93-958d-3f211508f28d",
"control_token": "base64url-encoded-random-secret",
"viewer_url": "https://my.domain/g/0199aeb4-6c00-7e93-958d-3f211508f28d",
"state": {"lifecycle": "waiting"}
}
```
The viewer URL appears only in the `new_game` envelope, not in its nested
state or later observations. The example UUID and token are illustrative.
`new_game` retains the
existing character options and requires `model_slug`. Reject a missing,
empty, or invalid slug before allocating a game ID, creating files, or
inserting a record. Accept 1 to 128 bytes of printable ASCII and store the
string exactly as supplied. The server cannot derive or verify the actual
model identity from an ordinary MCP tool call, so this field is caller
supplied. Do not infer identity from a user-agent string.
For example, `new_game` may receive `{"name":"Agent",
"model_slug":"example-model"}`. Add `model_slug` to its MCP input schema's
`required` list with matching length and character constraints.
Return a stable `GAME_NOT_FOUND` error for unknown or expired IDs, a
`FORBIDDEN` error for an incorrect control token, and `GAME_CLOSING` when
cleanup has begun. Keep existing stale-input and wrong-input-kind errors.
Do not reveal whether a token was almost correct. Rate-limit repeated
failures at the HTTP edge. When the service has reached its active-game
capacity, `new_game` returns `CAPACITY_REACHED` without creating files or
starting a worker. Admission and UUID insertion must be atomic, so
concurrent requests cannot exceed the configured maximum.
## 5. Idle and absolute game lifetime
Each active game records `created_at` and `last_agent_activity` using a
monotonic clock. The default idle timeout is 600 seconds (10 minutes).
The default absolute lifetime is 86,400 seconds (24 hours) from accepted
game creation. Both values are deployment configuration, not constants
embedded in lifecycle code.
Set `last_agent_activity` when `new_game` is accepted and refresh it when
a valid, authorized game-specific MCP call begins, including `observe`.
An invalid call does not refresh it. Spectator page loads and state polling
never refresh it. A call already waiting for a worker response does not
extend the idle deadline again merely because the wait continues. The
absolute deadline never moves, regardless of agent activity. The earlier
of the two deadlines ends the game.
A lifecycle timer wakes at the earliest game deadline; it may also sweep
periodically as a fallback. Every game-specific MCP call checks both
deadlines under the session lock before it is accepted, so no input can
be admitted after the 24-hour limit even if timer execution is delayed.
At a deadline, cleanup performs these steps:
1. Read the monotonic time and select sessions past either deadline.
2. Mark each selected session `closing` under its session lock. New MCP
calls then fail with `GAME_CLOSING`.
3. Publish a terminal observation with reason `idle_timeout` or
`time_limit`, and notify waiting MCP requests. The next spectator poll
observes the terminal state.
4. Terminate and reap the worker, including its IPC reader threads.
5. Finalize the SQLite record with the same end reason and the latest
known floor, then remove the per-game directory.
6. Remove the registry entry. Existing request handles may finish reading
the terminal observation before the session object is destroyed.
An accepted call racing with the sweeper wins only if it refreshes activity
before the idle deadline and before the session becomes `closing`.
The absolute deadline takes precedence if both deadlines have passed.
Serialize these decisions with the session lock; do not rely on a later
timestamp check. Worker termination can incur ordinary scheduler latency,
but the application must accept no further game input after the absolute
deadline. An operation stuck in the worker still ends at the deadline;
cleanup must not wait indefinitely for that operation to return.
Normal NetHack completion, explicit `quit_game`, worker failure, idle
timeout, and the absolute time limit all finalize a record. Store a
specific `end_reason`: `ascended`, `escaped`, `died`, `quit`, `failed`,
`idle_timeout`, or `time_limit`. A completed game is won exactly when
`end_reason = 'ascended'`; no separate win column is needed. Finalization
must be idempotent because a worker-exit callback and the lifecycle timer
may race. Record the first terminal outcome that wins the session
transition. After cleanup, the UUID is never reused.
## 6. Outcome, floor, and character metadata
The character name is the validated name accepted by `new_game`, not text
scraped from the status bar. Capture the wall-clock start time when game
creation is accepted. Capture the wall-clock end time when the session
becomes terminal. Store timestamps as Unix seconds UTC; format them
for display in the visitor's local timezone with an explicit timezone
label. Monotonic time is used only for expiry decisions.
Capture the NetHack end condition from its native `how` value, not from
exit status or the prose shown in the window. Add a narrow engine
integration hook at finalization that sends a terminal-result IPC message
before `nh_terminate()`. Map `ASCENDED` to `ascended`, `ESCAPED` to
`escaped`, `QUIT` to `quit`, and death conditions to `died`. An engine
panic or missing terminal-result message is `failed`, unless an earlier
administrative quit or deadline already ended the session. This makes
ascension an explicit, verifiable outcome in the parent server. The root
page can display “Won” exactly for records
whose `end_reason` is `ascended`.
The existing `status.dungeon_level` field is display text. Parsing a
number from that text is too fragile for a permanent record, especially
when NetHack displays a named branch. Extend the worker's snapshot message
with a private, structured location sample obtained from the engine's
current dungeon position at each published boundary:
```json
{"depth": 7}
```
Verify the pinned NetHack 5.0 depth API while implementing the adapter.
`depth` means NetHack's absolute dungeon depth, not the number of floors
the player has visited. Branches can make a bare number ambiguous; the
record intentionally stores only the numeric floor statistic.
Do not expose hidden terrain or other unseen engine state in the agent
observation. The depth sample is used by the parent for record keeping;
the already visible location text can continue to drive the viewer.
On each valid location sample, set `last_depth` to the current depth and
`deepest_depth` to the maximum observed depth. The requested “lowest floor
visited” is this deepest numerical depth. After finalization, `last_depth`
is the floor where the game ended; no separate end-depth copy is needed.
If the worker ends before publishing a valid location, these fields remain
SQL `NULL` and the UI displays “Unknown.”
Never replace a known deepest depth with a null sample. A new location
sample should update SQLite only when the depth changes, avoiding
a write for every keypress.
## 7. SQLite record store
Put the SQLite database on persistent storage outside `/tmp`. Runtime
game directories may live in tmpfs and are deleted after completion.
If the database lives in `/tmp`, a host restart may erase the records,
which would defeat their purpose. Configure a separate `--database` path
and `--data-root` path. The database path and its parent directory must be
writable by the service account.
Use libmw's SQLite wrapper through a narrow game-record store class. Enable
`LIBMW_BUILD_SQLITE` in CMake and link `mw::sqlite`. Open the database with
`mw::SQLite::connectFile()`, use `mw::SQLiteStatement` and `bind()` for
parameterized queries, and propagate `mw::E<>` errors rather than throwing.
The pinned libmw wrapper already enables WAL and foreign keys on connect and
accepts a bounded busy timeout, defaulting to 5,000 ms. Create the schema
with a numbered migration (`PRAGMA user_version`) at startup and keep
transactions short. SQLite's [WAL documentation](https://www.sqlite.org/wal.html)
explains why readers can proceed alongside the single writer and why the
database must be on a local filesystem rather than a network filesystem.
The initial schema is:
```sql
CREATE TABLE game_records (
game_id TEXT PRIMARY KEY,
character_name TEXT NOT NULL,
model_slug TEXT NOT NULL CHECK (length(model_slug) BETWEEN 1 AND 128),
started_at_s INTEGER NOT NULL,
last_activity_at_s INTEGER NOT NULL,
ended_at_s INTEGER,
end_time_kind TEXT,
end_reason TEXT,
last_depth INTEGER,
deepest_depth INTEGER,
CHECK (end_reason IS NULL OR end_reason IN
('ascended', 'escaped', 'died', 'quit', 'failed',
'idle_timeout', 'time_limit', 'interrupted')),
CHECK ((ended_at_s IS NULL) = (end_reason IS NULL))
);
CREATE INDEX game_records_recent_idx
ON game_records (ended_at_s DESC, game_id DESC)
WHERE ended_at_s IS NOT NULL;
```
`last_depth` is updated while a game is active and becomes its ending floor
when the record is completed. `last_activity_at_s` is a wall-clock audit
field; it is not used to calculate live expiry. `end_time_kind` is
`observed` for normal terminal
transitions and `recovery` when startup discovers an orphaned active row.
For a recovered row, the actual death time is unknown; `ended_at_s` is the
recovery time and the UI should say “recovered after interruption.”
Rows are inserted before publishing a successful `new_game` result. If
worker startup fails, finalize the row as `failed` and clean its directory.
The unique database key is checked before starting a worker.
The store exposes explicit operations: `insertGame`, `updateLocation`,
`updateActivity`, `finishGame`, `recentGames`, `getGame`, and
`recoverInterruptedGames`. `finishGame` updates only rows whose end time
is null, allowing the caller to see whether another path already finalized
the record. Use a transaction to pair each record transition with its
related database writes. Never hold a session or registry mutex while
waiting for SQLite's write lock.
On startup, mark unfinished rows `interrupted` and record recovery time,
because no NetHack worker survives an application restart in this phase.
Then remove orphaned per-game directories under the configured data root.
Only delete directories whose names validate as this service's UUIDv7
format; never recurse through an arbitrary supplied path. If deletion
fails, log it and retry during later maintenance without erasing the
record. Back up the SQLite database with SQLite's backup API or an
equivalent database-aware method; copying the main file alone while WAL
mode is active can omit recent transactions.
## 8. Public pages and state API
The root page contains a short description of the service, a link or
instructions for connecting an MCP client, and ten completed records.
Query exactly the latest ten rows ordered by `ended_at_s DESC, game_id
DESC`. Each row displays character name, start
and end times, end reason, deepest floor, end floor, and the supplied model
slug. A game that has started but is still running
does not appear in this completed-record list.
`GET /g/<game-id>` serves the spectator shell for an active game. A
completed game redirects to `/`, where recent completed games are listed.
A valid UUID with no record gets 404; an invalid path gets 404 without
hitting the database. Render names and model slugs as text, never as raw
HTML. Completed rows have no links to individual game pages.
The browser loads active state from `GET /api/games/<game-id>/state`.
Retain conditional requests with a game-specific ETag. Each request returns
immediately with a snapshot or `304 Not Modified`; the browser waits two
seconds after that response before polling again. Once the live session is
gone, this API returns `410 Gone`
for a known completed record, or `404 Not Found` for an unknown ID; the
browser then returns to the root page. An ETag must include the game ID
and revision, so a cached revision from another game cannot match. State
responses include only the ten most recent messages. The worker retains the
same ten-message tail for MCP tool results, so messages do not grow across
repeated observations. Set `messages_truncated` after history eviction; for
`observe(after_message_id)`, report whether messages newer than that cursor
were lost.
Provide a compact `GET /api/recent` response for the root page, or render
the ten rows directly in server HTML. Either route must use the same SQL
ordering and output fields. Give successful pages a bounded cache policy;
game-control responses and tokens must not be cached. The root page and
spectator pages contain no gameplay controls.
At high spectator counts, periodic polls can still consume substantial
HTTP capacity during network spikes. Measure concurrent viewers as well as
concurrent games. Section 11 examines the worker cost.
## 9. Public deployment and resource bounds
The listener defaults to loopback and its address is configurable through
`[server].listen_address`. For public deployment, run it on loopback behind a
TLS reverse proxy. Configure the public base URL in the application. Let the
reverse proxy enforce its public Host and Origin policy; the application does
not inspect those headers. The application uses the socket peer address and
ignores forwarded address headers. When all requests pass through one proxy,
per-client rate limits therefore apply to the proxy connection address.
Expose all operating limits as startup configuration. Keep the following
defaults where behavior already exists or a value has been chosen here;
require an explicit deployment value for capacity limits that depend on
the host. Validate values at startup and reject zero or negative durations
and capacities. Settings load at startup; changing them requires a restart.
Running games are marked interrupted on restart, so there is no live
deadline migration in this phase.
| Setting | Default or deployment value | Effect |
| --- | --- | --- |
| `idle_timeout_seconds` | 600 | End a game after no valid agent call |
| `max_game_duration_seconds` | 86400 | End a game 24 hours after creation |
| `lifecycle_sweep_seconds` | 15 | Fallback scan and cleanup cadence |
| `max_active_games` | Required deployment value | Refuse new games at capacity |
| `new_games_per_client` | Required deployment value | Limit creation rate |
| `new_game_rate_window_seconds` | Required deployment value | Rate window |
| `max_concurrent_requests` | Required deployment value | Bound HTTP work |
| `max_open_connections` | Required deployment value | Bound sockets |
| `max_mcp_body_bytes` | 1048576 | Reject oversized MCP requests |
| `max_worker_output_bytes` | Required deployment value | Bound diagnostics |
The existing `--port` and `--data-root` options remain. Add a configuration
file or equivalent startup flags for these settings, with command-line
overrides documented in `--help`. Runtime reload is not required in this
phase. Field-validation limits, such as character-name and model-slug
length, also belong in one named configuration structure rather than
scattered numeric literals; defaults can preserve the current protocol
limits. Reverse-proxy rate and connection limits should agree with the
application values.
An anonymous public `new_game` endpoint needs admission control even
though individual games expire. Return a clear capacity error and a
retry hint when full. Bound worker output and record field sizes so one
game cannot consume unbounded parent memory or logs.
The current worker supervisor creates one child process and two parent
threads per game. A target of 1,000 active games therefore implies about
3,000 Linux tasks before HTTP workers. Configure the service's cgroup task
and memory limits with headroom, and choose the application admission cap
from measurements rather than the 1.5 MB runtime-file estimate alone.
Track active games, live worker count, task count, database write latency,
tmpfs bytes, memory, request latency, and expiry/cleanup failures.
## 10. Implementation sequence and validation
Implement in small increments so the single-game behavior remains usable
until routing changes are complete:
1. Add UUIDv7 generation and canonical validation, then replace the
current timestamp-and-counter game ID.
2. Add the SQLite store and migration. Insert, update, finalize, query,
and recover records through its narrow API.
3. Add structured worker location and terminal-result metadata. Record
depth changes and native end outcomes. Confirm depth behavior in
ordinary levels and at least one branch.
4. Introduce the game manager. Move session ownership and lookup there,
and route all MCP tools by game ID with a separate control token.
5. Add configurable idle and absolute deadlines with a race-safe terminal
transition. Wake observers, reap workers, and delete runtime directories.
6. Add the root listing, per-game spectator path, and per-game state API.
Make public URLs configurable.
7. Add public-host configuration, reverse-proxy deployment files, limits,
and operational metrics.
Before release, verify these behaviors: simultaneous games cannot affect
one another; input for one ID or token cannot reach another game; missing
or incorrect credentials fail; exactly ten most recent completed records
appear in order; ascension is recorded as `ascended` and other normal
outcomes are distinguished; lowest and ending floors survive worker exit;
`new_game` rejects a missing or invalid model slug and records a valid one;
spectators do not reset idle time; an authorized agent observation does;
both the idle and 24-hour limits wake waiting calls; a call near the
absolute deadline cannot extend it; concurrent timeout and worker exit
finalize once; a restart
marks orphaned rows interrupted; runtime directories are removed; the
reverse proxy rejects unexpected public hosts. Load-test increasing game
and viewer counts on the actual host, recording task count, PSS or cgroup
memory, tmpfs use, request latency, and failure rates before setting a
production active-game limit.
The main unresolved product choice is whether anonymous agents may create
games indefinitely within rate limits, or whether account-based admission
will be required. This design supports anonymous creation with a temporary
control token and strict capacity controls. Every game records the required
caller-supplied model slug; it is not verified by the server.
## 11. Spectator transport and slow client connections
The spectator API now uses periodic conditional GETs. A matching ETag gets
an immediate `304 Not Modified`; otherwise the handler returns the current
snapshot. The browser waits two seconds after each response before starting
another request. It waits five seconds after a network error. Because it
never overlaps requests, a browser-visible request that takes more than ten
seconds does not accumulate additional polls. The HTTP server allows one
request per backend connection, so idle keep-alive sockets do not retain
cpp-httplib workers. A slow response can still occupy a worker until the
server finishes sending it.
The previous 15-second long poll held a worker for each unchanged viewer.
With 64 HTTP workers and 48 allowed viewer waits, only 16 workers remained
for MCP and other requests, before considering idle keep-alive sockets.
The transport choices discussed were:
| Transport | Worker use | Other cost |
| --- | --- | --- |
| Previous long poll | One per waiting viewer | Sustained worker use |
| SSE in cpp-httplib | One per open stream | Fewer requests |
| Chosen periodic GET | Released after response and socket close | More requests; update lag |
| Asynchronous HTTP | No worker per idle viewer | More implementation work |
SSE is a one-way HTTP stream from server to browser. Its value here depends
on the HTTP implementation: using SSE with a blocking cpp-httplib handler
does not solve worker exhaustion. Apache would also have to proxy each open
stream. For periodic GETs, the browser should start a new request only after
the previous one finishes; otherwise a network spike could accumulate
overlapping polls. With `N` continuously watching browsers and a poll
interval of `T` seconds, the steady request rate is roughly `N / T` per
second, before accounting for request duration. An asynchronous server could
retain the same ETag and long-poll
API: it would register a pending request, return the thread to an event
loop, and complete the request when the game's revision changes or a timer
expires. NetHack worker and SQLite operations must stay off that event
loop.
The planned reverse proxy is Apache HTTP Server. The intended host frequently
has network spikes that can make a browser-visible request take more than
ten seconds. A short application handler does not guarantee a short
browser-visible request, and a slow browser may cause proxy backpressure.
Apache's [event MPM](https://httpd.apache.org/docs/2.4/mod/event.html)
can handle idle client keep-alive connections without dedicating a worker,
but its documentation says proxied response bodies may still require a
worker while a slow client receives them. Apache's
[proxy module](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html) uses
bounded transfer buffers and may reuse backend connections. Its
[`disablereuse` option](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#proxypass)
can close a backend connection after a request; Apache's
[`mod_buffer`](https://httpd.apache.org/docs/2.4/mod/mod_buffer.html) may let
a backend finish sooner by buffering output, subject to buffer size and
memory use. Neither should be assumed to isolate the C++ server from all
slow clients without checking the actual Apache configuration and traffic.
Periodic GET is the selected transport for this version. Inspect the
deployed Apache MPM and proxy settings, measure typical and maximum state
response sizes, and load-test concurrent viewers during network spikes.
Record busy Apache and C++ workers, backend response time, viewer update
delay, rejected connections, and MCP latency. Production limits must
preserve enough capacity for gameplay calls during those spikes.