Changes
diff --git a/designs/design-0-mcp.md b/designs/design-0-mcp.md
index fcb81db..7a6e7cd 100644
--- a/designs/design-0-mcp.md
+++ b/designs/design-0-mcp.md
@@ -1,6 +1,6 @@
# NetHack MCP server and spectator UI
-Status: proposed design; implementation has not started.
+Status: Implemented.
## 1. Purpose and decisions
diff --git a/designs/design-1-multigame.md b/designs/design-1-multigame.md
new file mode 100644
index 0000000..6433381
--- /dev/null
+++ b/designs/design-1-multigame.md
@@ -0,0 +1,466 @@
+# 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 or completed-game summary |
+| `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 and accepts only localhost `Host` values. `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, reading SQLite, or
+holding a long-poll response. 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 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 and browser requests. Never leave
+ a long poll waiting for its full timeout.
+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 and completed-game summary 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 only 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 links to `/g/<game-id>` and 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. For a
+completed game whose runtime state has been removed, it serves a summary
+from SQLite with the same record fields. 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.
+
+The browser loads active state from `GET /api/games/<game-id>/state`.
+Retain conditional requests and the current 15-second long-poll behavior,
+but make the observation store and ETag game-specific. A terminal event
+wakes the poll. 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 loads the record summary. An ETag must include the game ID
+and revision, so a cached revision from another game cannot match.
+
+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, blocking long polls can consume the HTTP
+server's worker pool. Measure concurrent viewers as well as concurrent
+games. If the pool becomes the bottleneck, move state notifications to an
+event-driven mechanism or Server-Sent Events; WebSocket is unnecessary for
+the read-only viewer. Do not hold registry locks while a poll waits.
+
+## 9. Public deployment and resource bounds
+
+Run the C++ listener on loopback behind a TLS reverse proxy. Configure the
+public base URL and an explicit allowed Host and Origin list. Preserve the
+current validation against hostile origins, adapted for the public host;
+do not accept any Host value by default. The reverse proxy should forward
+the original host and scheme in a controlled way. Never trust an arbitrary
+client-supplied forwarded header when constructing viewer URLs.
+
+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 |
+| `state_long_poll_seconds` | 15 | Maximum wait for one viewer state call |
+| `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_viewer_long_polls` | Required deployment value | Bound waiting viewers |
+| `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, per-game state API,
+ and completed-game summary. 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; public
+Host and Origin rules reject unexpected values. 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.