BareGit

Add public multi-game NetHack service

- Manage isolated games with UUIDv7 IDs, control tokens, and lifetime limits.
- Persist game records, native outcomes, and floor history in SQLite.
- Serve per-game viewers with web assets embedded at build time.
- Add public deployment limits, metrics, and proxy guidance.
- Document Apache spectator transport tradeoffs.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 22 20:17:34 2026 -0700
Commit: 1c956be27eadf7e330c8e42cabd57a6c0a309be2

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index dbfc7e9..ef0b0a4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -37,8 +37,10 @@ FetchContent_Declare(
 set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
 FetchContent_MakeAvailable(libmw)
 find_package(Threads REQUIRED)
+find_package(OpenSSL REQUIRED COMPONENTS Crypto)
 
 if(NETHACK_BUILD_ENGINE)
     find_path(NETHACK_LUA_INCLUDE_DIR
@@ -133,7 +135,10 @@ endif()
 
 set(NETHACK_MCP_SOURCES
     src/engine_process.cpp
+    src/game_manager.cpp
+    src/game_record_store.cpp
     src/game_session.cpp
+    src/identity.cpp
     src/main.cpp
     src/mcp_server.cpp
     src/observation_store.cpp
@@ -141,13 +146,27 @@ set(NETHACK_MCP_SOURCES
     src/game_http_server.cpp)
 
 set(STATIC_FILES
+    "${CMAKE_CURRENT_SOURCE_DIR}/web/home.html"
+    "${CMAKE_CURRENT_SOURCE_DIR}/web/record.html"
+    "${CMAKE_CURRENT_SOURCE_DIR}/web/home.css"
+    "${CMAKE_CURRENT_SOURCE_DIR}/web/local_time.js"
     "${CMAKE_CURRENT_SOURCE_DIR}/web/index.html"
     "${CMAKE_CURRENT_SOURCE_DIR}/web/viewer.css"
     "${CMAKE_CURRENT_SOURCE_DIR}/web/viewer.js"
     "${CMAKE_CURRENT_SOURCE_DIR}/web/kreative_square.ttf")
-include(cmake/embed_assets.cmake)
-list(APPEND NETHACK_MCP_SOURCES
+set(EMBEDDED_ASSETS_SOURCE
     "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp")
+add_custom_command(
+    OUTPUT "${EMBEDDED_ASSETS_SOURCE}"
+    COMMAND "${CMAKE_COMMAND}"
+        "-DOUTPUT=${EMBEDDED_ASSETS_SOURCE}"
+        "-DWEB_ROOT=${CMAKE_CURRENT_SOURCE_DIR}/web"
+        "-DSTATIC_FILES=${STATIC_FILES}"
+        -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_assets.cmake"
+    DEPENDS ${STATIC_FILES} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_assets.cmake"
+    VERBATIM)
+list(APPEND NETHACK_MCP_SOURCES
+    "${EMBEDDED_ASSETS_SOURCE}")
 
 if(NETHACK_BUILD_ENGINE)
     list(APPEND NETHACK_MCP_SOURCES
@@ -166,6 +185,8 @@ target_compile_definitions(nethack_mcp PRIVATE
 target_link_libraries(nethack_mcp PRIVATE
     mw::mw
     mw::http-server
+    mw::sqlite
+    OpenSSL::Crypto
     nlohmann_json::nlohmann_json
     Threads::Threads)
 if(NETHACK_BUILD_ENGINE)
diff --git a/README.md b/README.md
index 7865fd4..6e4c93c 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,94 @@
 # nethack-mcp
 
-This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs it
-in a worker process, and exposes MCP over Streamable HTTP alongside a
-read-only loopback viewer.
+This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs
+each game in an isolated worker process, and serves multiple games through
+one MCP endpoint. Spectators can watch active games and browse completed
+records without receiving gameplay controls.
 
-The default build uses system Lua 5.4 headers and library. CMake checks the
-Lua header version before configuring NetHack; Lua 5.4.8 is used by the
-current pinned NetHack build.
+## Build
 
-Build and test it with:
+The default build uses system Lua 5.4 headers and library. CMake checks the
+Lua header version before configuring NetHack.
 
 ```sh
 cmake -S . -B build
 cmake --build build -j24
-ctest --test-dir build --output-on-failure
 ```
 
-The produced library is:
+The produced engine library is `build/nethack-work/src/libnethack.a`.
 
-```text
-build/nethack-work/src/libnethack.a
-```
+HTML, JavaScript, CSS, and font assets are maintained as ordinary files in
+`web/`. A build step embeds them in a generated C++ translation unit, which is
+compiled into the executable. Asset changes regenerate that translation unit.
 
-Start one server manually with:
+## Local server
 
 ```sh
 ./build/nethack_mcp --data-root /tmp/nethack-mcp --port 8765
 ```
 
-Point each MCP client at `http://127.0.0.1:8765/mcp` using its Streamable
-HTTP transport. The server stays alive independently of client connections
-and owns one active game shared by those clients. Stop it with Ctrl-C.
+The MCP endpoint is `http://127.0.0.1:8765/mcp`; the read-only landing page is
+at `http://127.0.0.1:8765/`. Game records are stored by default at
+`~/.local/share/nethack-mcp/games.sqlite3`, outside the temporary runtime
+directory. Stop the server with Ctrl-C. A server restart ends all active
+games and records them as interrupted.
+
+Create games with the MCP `new_game` tool and a caller-supplied `model_slug`.
+Keep the returned `game_id` and `control_token`; every later game tool call
+must include both. The token is returned once and acts as a temporary game
+password. Share only the `/g/<game-id>` viewer URL with spectators.
+The endpoint supports the sessionless MCP `2026-07-28` protocol and the
+2025 handshake revisions.
+
+The home page lists the ten most recently completed games. Active games use
+`/g/<game-id>` and poll `/api/games/<game-id>/state`; completed game pages
+show their stored outcome and floor record.
+
+## Public deployment
+
+Run the application on loopback behind a TLS reverse proxy. Configure the
+public hostname exactly in the Host and Origin allowlists, and trust only the
+proxy address that connects to the application. The database directory must
+be persistent, local (not a network filesystem), and writable only by the
+service account. The game data root can be temporary storage.
+
+Example application command (replace the paths and hostname and choose limits
+that fit the host):
+
+```sh
+./build/nethack_mcp \
+  --port 8765 \
+  --data-root /run/nethack-mcp/games \
+  --database /var/lib/nethack-mcp/games.sqlite3 \
+  --public-base-url https://nethack.example/ \
+  --allowed-host nethack.example \
+  --allowed-origin https://nethack.example \
+  --trusted-proxy 127.0.0.1 \
+  --max-active-games 8 \
+  --new-games-per-client 8 \
+  --new-game-rate-window-seconds 3600 \
+  --max-rate-limit-clients 4096 \
+  --max-concurrent-requests 64 \
+  --max-open-connections 128 \
+  --max-viewer-long-polls 48 \
+  --max-worker-output-bytes 1048576
+```
+
+Public mode requires explicit host-dependent capacity settings. The default
+idle timeout is 600 seconds and the absolute game lifetime is 86,400 seconds.
+Use `--help` for the full list of startup options. The sample reverse proxy
+configuration in `deploy/nginx.conf.example` includes TLS termination,
+connection limits, request limits, and the forwarded headers expected by the
+application.
+
+Set the application limits together with the host's task, memory, and file
+descriptor limits. Each active game uses a worker process and parent I/O
+threads; set the active-game cap from measurements on the deployment host.
+`/metrics` reports active games and workers, task and memory estimates,
+runtime bytes, database write latency, HTTP latency, viewer waits, and expiry
+cleanup failures. Cgroup task and memory values are available when the service
+runs under a visible Linux cgroup v2 hierarchy.
 
-The browser viewer is available at `http://127.0.0.1:8765/` and serves only
-read-only state from the active session. Its map uses the bundled Kreative
-Square font, whose character cells are designed for square text graphics.
-The viewer's HTML, CSS, JavaScript, and font files live under `web/` and are
-embedded into the executable during the CMake configure step.
+Back up the SQLite database with SQLite's backup support while the service is
+running, for example with the `sqlite3` shell's `.backup` command. Do not copy
+only the main database file while WAL mode is active.
diff --git a/cmake/build_nethack.cmake b/cmake/build_nethack.cmake
index 3adc2ac..c1f0872 100644
--- a/cmake/build_nethack.cmake
+++ b/cmake/build_nethack.cmake
@@ -48,6 +48,24 @@ if(NOT setup_result EQUAL 0)
         "${setup_output}\n${setup_error}")
 endif()
 
+# Publish NetHack's native end condition before the engine tears itself down.
+set(NETHACK_END_SOURCE "${NETHACK_SOURCE_DIR}/src/end.c")
+file(READ "${NETHACK_END_SOURCE}" NETHACK_END_CONTENT)
+string(REPLACE
+    "#include \"hack.h\""
+    "#include \"hack.h\"\nextern void nethack_mcp_end_result(int) __attribute__((weak));"
+    NETHACK_END_CONTENT "${NETHACK_END_CONTENT}")
+string(FIND "${NETHACK_END_CONTENT}"
+    "    program_state.gameover = 1;" NETHACK_END_HOOK_POSITION)
+if(NETHACK_END_HOOK_POSITION EQUAL -1)
+    message(FATAL_ERROR "Could not locate NetHack's terminal hook point")
+endif()
+string(REPLACE
+    "    program_state.gameover = 1;"
+    "    if (nethack_mcp_end_result)\n        nethack_mcp_end_result(how);\n    program_state.gameover = 1;"
+    NETHACK_END_CONTENT "${NETHACK_END_CONTENT}")
+file(WRITE "${NETHACK_END_SOURCE}" "${NETHACK_END_CONTENT}")
+
 # The upstream libnh rule names this archive even when system Lua is used.
 # It is a dependency marker only; the executable links the system library.
 file(MAKE_DIRECTORY "${NETHACK_SOURCE_DIR}/lib/lua")
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
index b930a6e..2e560ba 100644
--- a/cmake/embed_assets.cmake
+++ b/cmake/embed_assets.cmake
@@ -1,11 +1,14 @@
 # Generate byte arrays without depending on external embedding tools.
+if(NOT DEFINED OUTPUT OR NOT DEFINED WEB_ROOT OR NOT DEFINED STATIC_FILES)
+    message(FATAL_ERROR "Asset embedding needs OUTPUT, WEB_ROOT, and STATIC_FILES")
+endif()
+
 set(EMBEDDED_SOURCE
     "#include \"embedded_assets.hpp\"\n\n"
     "namespace nethack_mcp\n{\n\nnamespace\n{\n")
 set(ASSET_ENTRIES "")
 set(ASSET_INDEX 0)
 foreach(ASSET IN LISTS STATIC_FILES)
-    set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ASSET}")
     file(READ "${ASSET}" ASSET_HEX HEX)
     string(LENGTH "${ASSET_HEX}" ASSET_SIZE)
     math(EXPR ASSET_SIZE "${ASSET_SIZE} / 2")
@@ -14,8 +17,7 @@ foreach(ASSET IN LISTS STATIC_FILES)
     string(APPEND EMBEDDED_SOURCE
         "const unsigned char ASSET_${ASSET_INDEX}[] = "
         "{${ASSET_BYTES}0};\n")
-    file(RELATIVE_PATH ASSET_NAME "${CMAKE_CURRENT_SOURCE_DIR}/web"
-        "${ASSET}")
+    file(RELATIVE_PATH ASSET_NAME "${WEB_ROOT}" "${ASSET}")
     get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
     if(ASSET_EXTENSION STREQUAL ".html")
         set(ASSET_TYPE "text/html; charset=utf-8")
@@ -45,9 +47,6 @@ string(APPEND EMBEDDED_SOURCE
     "const EmbeddedAsset ASSETS[] = {\n${ASSET_ENTRIES}};\n}\n\n"
     "std::span<const EmbeddedAsset> embeddedAssets()\n{\n"
     "    return ASSETS;\n}\n\n}\n")
-file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
-file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
-    "${EMBEDDED_SOURCE}")
-configure_file(
-    "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
-    "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp" COPYONLY)
+get_filename_component(OUTPUT_DIRECTORY "${OUTPUT}" DIRECTORY)
+file(MAKE_DIRECTORY "${OUTPUT_DIRECTORY}")
+file(WRITE "${OUTPUT}" "${EMBEDDED_SOURCE}")
diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example
new file mode 100644
index 0000000..c568ce6
--- /dev/null
+++ b/deploy/nginx.conf.example
@@ -0,0 +1,48 @@
+events
+{
+    worker_connections 1024;
+}
+
+http
+{
+    limit_conn_zone $binary_remote_addr zone=nethack_connections:10m;
+    limit_req_zone $binary_remote_addr zone=nethack_requests:10m rate=30r/s;
+
+    upstream nethack_mcp
+    {
+        server 127.0.0.1:8765;
+        keepalive 32;
+    }
+
+    server
+    {
+        listen 80;
+        server_name nethack.example;
+        return 308 https://nethack.example$request_uri;
+    }
+
+    server
+    {
+        listen 443 ssl;
+        server_name nethack.example;
+
+        ssl_certificate /etc/letsencrypt/live/nethack.example/fullchain.pem;
+        ssl_certificate_key /etc/letsencrypt/live/nethack.example/privkey.pem;
+        server_tokens off;
+        client_max_body_size 1m;
+
+        location /
+        {
+            limit_conn nethack_connections 32;
+            limit_req zone=nethack_requests burst=60 nodelay;
+
+            proxy_pass http://nethack_mcp;
+            proxy_http_version 1.1;
+            proxy_set_header Host $host;
+            proxy_set_header X-Forwarded-For $remote_addr;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_set_header Connection "";
+            proxy_read_timeout 30s;
+        }
+    }
+}
diff --git a/designs/design-1-multigame.md b/designs/design-1-multigame.md
index 6433381..a1d11f6 100644
--- a/designs/design-1-multigame.md
+++ b/designs/design-1-multigame.md
@@ -364,9 +364,8 @@ 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.
+games. Section 11 examines the worker cost and possible replacements.
+Do not hold registry locks while a poll waits.
 
 ## 9. Public deployment and resource bounds
 
@@ -464,3 +463,68 @@ 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 current spectator API uses a 15-second conditional long poll. When its
+ETag matches the latest game revision, the handler waits for a new snapshot
+or a timeout. The browser starts another poll as soon as the response
+arrives. The pinned [cpp-httplib server](https://github.com/yhirose/cpp-httplib/blob/278c2979e8c68468960c3073e28e1c51b098d6a4/httplib.h)
+assigns each accepted connection to a thread-pool task. A worker remains
+occupied while the handler waits and may remain attached to the socket
+during HTTP keep-alive. A continuously watching spectator can therefore
+occupy a worker almost continuously.
+
+The current example limits are 64 HTTP workers and 48 simultaneous viewer
+long polls. At that limit, only 16 workers remain for MCP calls and other
+HTTP requests, before considering idle keep-alive sockets. The bounded
+task queue limits additional accepted connections, but it does not make
+waiting polls cheap. These numbers require measurement on the deployment
+host rather than an assumption that each spectator uses negligible work.
+
+The alternatives discussed so far have different costs:
+
+| Transport | Worker use | Other cost |
+| --- | --- | --- |
+| Current long poll | One per waiting viewer | Sustained worker use |
+| SSE in cpp-httplib | One per open stream | Fewer requests |
+| 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. 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.
+
+This discussion has not selected a replacement for the current long poll.
+Before choosing one, 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. The choice
+must preserve enough capacity for gameplay calls during those spikes.
diff --git a/include/engine_process.hpp b/include/engine_process.hpp
index a077819..7cc81bc 100644
--- a/include/engine_process.hpp
+++ b/include/engine_process.hpp
@@ -1,6 +1,7 @@
 #pragma once
 
 #include <atomic>
+#include <cstddef>
 #include <functional>
 #include <memory>
 #include <mutex>
@@ -31,13 +32,14 @@ public:
 
     /// Spawn a worker and send its validated start message.
     bool start(const Json& start_message, EventCallback callback,
-               std::string& error);
+               std::string& error,
+               std::size_t max_diagnostic_bytes = 1024U * 1024U);
 
     /// Send one validated input message to the worker.
     bool send(const Json& message, std::string& error);
 
     /// Request termination and wait for the child and reader threads.
-    void terminate();
+    void terminate(bool force = false);
 
     /// Report whether the child has not yet been reaped.
     bool running() const;
@@ -52,6 +54,7 @@ private:
     EventCallback callback_;
     std::thread reader_thread_;
     std::thread diagnostic_thread_;
+    std::size_t max_diagnostic_bytes_ = 1024U * 1024U;
     mutable std::mutex state_mutex_;
     std::atomic<bool> running_ = false;
 };
diff --git a/include/game_http_server.hpp b/include/game_http_server.hpp
index 4db25d8..58872ae 100644
--- a/include/game_http_server.hpp
+++ b/include/game_http_server.hpp
@@ -1,24 +1,29 @@
 #pragma once
 
 #include <atomic>
+#include <cstdint>
 #include <string>
 #include <string_view>
 #include <thread>
+#include <vector>
 
 #include <mw/http_server.hpp>
 
+#include "server_config.hpp"
+
 namespace nethack_mcp
 {
 
-class GameSession;
+class GameManager;
 class McpServer;
+struct GameRecord;
 
-/// Serve MCP and browser observations on one loopback listener.
+/// Serve shared MCP, landing, spectator, and completed-record routes.
 class GameHttpServer : public mw::HTTPServer
 {
 public:
-    /// Construct a server bound to the loopback address and requested port.
-    GameHttpServer(GameSession& session, McpServer& mcp, int port);
+    /// Construct the loopback listener from the manager's public policy.
+    GameHttpServer(GameManager& manager, McpServer& mcp);
 
     /// Stop the listener before destroying the server.
     ~GameHttpServer();
@@ -36,28 +41,37 @@ public:
     bool running() const;
 
 protected:
-    /// Register the MCP and viewer routes.
+    /// Register shared service, per-game, and static asset routes.
     void setup() override;
 
 private:
     bool validHost(const Request& request) const;
+    bool validOrigin(const Request& request) const;
     void serveStatic(std::string_view path, const Request& request,
                      Response& response);
     void servePage(const Request& request, Response& response);
+    void serveGamePage(const Request& request, Response& response);
     void serveScript(const Request& request, Response& response);
     void serveStyle(const Request& request, Response& response);
     void serveFont(const Request& request, Response& response);
     void serveState(const Request& request, Response& response);
     void serveHealth(const Request& request, Response& response);
+    void serveMetrics(const Request& request, Response& response);
     void serveMcp(const Request& request, Response& response);
     void rejectMcpStream(const Request& request, Response& response);
-    bool validOrigin(const Request& request) const;
+    void rejectRequest(Response& response) const;
+    std::string recentPage();
+    std::string recordPage(const GameRecord& record);
 
-    GameSession& session_;
+    GameManager& manager_;
     McpServer& mcp_;
-    int port_;
+    ServerConfig config_;
     std::thread server_thread_;
     std::atomic<bool> started_ = false;
+    mutable std::atomic<std::size_t> active_requests_ = 0;
+    mutable std::atomic<std::size_t> viewer_long_polls_ = 0;
+    mutable std::atomic<std::uint64_t> request_count_ = 0;
+    mutable std::atomic<std::uint64_t> request_latency_total_us_ = 0;
 };
 
 } // namespace nethack_mcp
diff --git a/include/game_manager.hpp b/include/game_manager.hpp
new file mode 100644
index 0000000..d95b2fa
--- /dev/null
+++ b/include/game_manager.hpp
@@ -0,0 +1,112 @@
+#pragma once
+
+#include <atomic>
+#include <chrono>
+#include <condition_variable>
+#include <deque>
+#include <filesystem>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <thread>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "game_record_store.hpp"
+#include "game_session.hpp"
+#include "server_config.hpp"
+
+namespace nethack_mcp
+{
+
+/// Own the active-game registry and route agent calls to isolated sessions.
+class GameManager
+{
+public:
+    /// Open the record store, recover old rows, and start the expiry timer.
+    static mw::E<std::unique_ptr<GameManager>> create(
+        ServerConfig config, std::filesystem::path runtime_source);
+
+    ~GameManager();
+
+    GameManager(const GameManager&) = delete;
+    GameManager& operator=(const GameManager&) = delete;
+
+    /// Create and admit a new game, returning its secret once to the caller.
+    ToolResult createGame(const Json& arguments, const std::string& client_id);
+
+    /// Authorize and dispatch one game-specific MCP tool call.
+    ToolResult dispatch(const std::string& tool_name, const Json& arguments,
+                        const std::string& client_id);
+
+    /// Return a shared active-game handle for read-only spectator access.
+    std::shared_ptr<GameSession> findActive(const std::string& game_id) const;
+
+    /// Return a durable record for a valid game ID, if one exists.
+    mw::E<std::optional<GameRecord>> getRecord(const std::string& game_id);
+
+    /// Return the ten most recently completed records.
+    mw::E<std::vector<GameRecord>> recentGames();
+
+    /// Stop expiry maintenance and terminate workers during server shutdown.
+    void shutdown();
+
+    /// Return the canonical public base URL used in generated viewer links.
+    const std::string& publicBaseUrl() const;
+
+    /// Return the configured game-independent HTTP service settings.
+    const ServerConfig& config() const;
+
+    /// Return the number of sessions currently present in the registry.
+    std::size_t activeGameCount() const;
+
+    /// Count worker child processes that are still running.
+    std::size_t activeWorkerCount() const;
+
+    /// Count files under the private runtime root for capacity monitoring.
+    std::uint64_t runtimeBytes() const;
+
+    /// Return cleanup failures seen by the expiry maintenance thread.
+    std::uint64_t expiryCleanupFailures() const;
+
+    /// Return latency of the most recent SQLite write in microseconds.
+    std::uint64_t databaseWriteLatencyMicroseconds() const;
+
+private:
+    GameManager(ServerConfig config,
+                std::filesystem::path runtime_source,
+                std::shared_ptr<GameRecordStore> records);
+
+    void sweepLoop();
+    void removeOrphanedDirectories();
+    void releaseReservation(const std::string& game_id);
+    bool allowCreation(const std::string& client_id,
+                       std::chrono::steady_clock::time_point now);
+    bool allowControlAttempt(const std::string& client_id);
+    void recordControlFailure(const std::string& client_id);
+    void clearControlFailures(const std::string& client_id);
+    void pruneClientHistory(std::chrono::steady_clock::time_point now);
+
+    struct ClientHistory
+    {
+        std::deque<std::chrono::steady_clock::time_point> creation_times;
+        std::deque<std::chrono::steady_clock::time_point> auth_failures;
+    };
+
+    ServerConfig config_;
+    std::filesystem::path runtime_source_;
+    std::shared_ptr<GameRecordStore> records_;
+    mutable std::mutex registry_mutex_;
+    std::unordered_map<std::string, std::shared_ptr<GameSession>> sessions_;
+    std::unordered_set<std::string> reserved_ids_;
+    std::size_t pending_creations_ = 0;
+    std::unordered_map<std::string, ClientHistory> client_history_;
+    std::mutex sweep_mutex_;
+    std::condition_variable sweep_condition_;
+    std::atomic<bool> stopping_ = false;
+    std::atomic<std::uint64_t> expiry_cleanup_failures_ = 0;
+    std::thread sweep_thread_;
+};
+
+} // namespace nethack_mcp
diff --git a/include/game_record_store.hpp b/include/game_record_store.hpp
new file mode 100644
index 0000000..2f17b42
--- /dev/null
+++ b/include/game_record_store.hpp
@@ -0,0 +1,93 @@
+#pragma once
+
+#include <filesystem>
+#include <cstdint>
+#include <atomic>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/database.hpp>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// A durable summary of one game, including nullable live and terminal fields.
+struct GameRecord
+{
+    /// Canonical lowercase UUIDv7 that identifies this game.
+    std::string game_id;
+    /// Validated character name supplied when the game was created.
+    std::string character_name;
+    /// Caller-supplied model identifier associated with the game.
+    std::string model_slug;
+    /// UTC Unix seconds when game creation was accepted.
+    std::int64_t started_at_s = 0;
+    /// UTC Unix seconds of the most recent valid agent call.
+    std::int64_t last_activity_at_s = 0;
+    /// UTC Unix seconds when the record became terminal, if completed.
+    std::optional<std::int64_t> ended_at_s;
+    /// Whether the ending time was observed or recovered after restart.
+    std::optional<std::string> end_time_kind;
+    /// Terminal game outcome or administrative ending reason.
+    std::optional<std::string> end_reason;
+    /// Last numeric dungeon depth observed before termination.
+    std::optional<int> last_depth;
+    /// Greatest numeric dungeon depth observed during the game.
+    std::optional<int> deepest_depth;
+
+    /// Return public record fields for HTML or JSON responses.
+    Json toJson() const;
+};
+
+/// Store compact records outside the per-game runtime directories.
+class GameRecordStore
+{
+public:
+    /// Open the persistent database and apply numbered schema migrations.
+    static mw::E<std::unique_ptr<GameRecordStore>> open(
+        const std::filesystem::path& database_path);
+
+    /// Insert a new active game, returning false for a UUID collision.
+    mw::E<bool> insertGame(const GameRecord& record);
+
+    /// Update current and deepest location after a depth change.
+    mw::E<void> updateLocation(const std::string& game_id, int depth);
+
+    /// Update the wall-clock audit time for accepted agent activity.
+    mw::E<void> updateActivity(const std::string& game_id,
+                               std::int64_t activity_at_s);
+
+    /// Finalize an active row once and report whether this call won.
+    mw::E<bool> finishGame(const std::string& game_id,
+                           std::int64_t ended_at_s,
+                           const std::string& end_time_kind,
+                           const std::string& end_reason);
+
+    /// Return exactly the ten most recently completed game records.
+    mw::E<std::vector<GameRecord>> recentGames();
+
+    /// Look up a completed or active row by its canonical game ID.
+    mw::E<std::optional<GameRecord>> getGame(const std::string& game_id);
+
+    /// Mark unfinished records interrupted after an application restart.
+    mw::E<void> recoverInterruptedGames(std::int64_t recovery_time_s);
+
+    /// Return latency of the most recent record-store write in microseconds.
+    std::uint64_t lastWriteLatencyMicroseconds() const;
+
+private:
+    explicit GameRecordStore(std::unique_ptr<mw::SQLite> database);
+
+    mw::E<void> migrate();
+
+    std::unique_ptr<mw::SQLite> database_;
+    std::mutex mutex_;
+    std::atomic<std::uint64_t> last_write_latency_us_ = 0;
+};
+
+} // namespace nethack_mcp
diff --git a/include/game_session.hpp b/include/game_session.hpp
index 062b163..e11248c 100644
--- a/include/game_session.hpp
+++ b/include/game_session.hpp
@@ -1,14 +1,18 @@
 #pragma once
 
 #include <atomic>
+#include <array>
 #include <chrono>
 #include <cstdint>
 #include <filesystem>
 #include <mutex>
+#include <optional>
 #include <string>
 
 #include "engine_process.hpp"
+#include "game_record_store.hpp"
 #include "observation_store.hpp"
+#include "server_config.hpp"
 
 namespace nethack_mcp
 {
@@ -26,20 +30,30 @@ struct ToolResult
     std::string message;
 };
 
-/// Own the single active game and serialize all gameplay input.
+/// Own one isolated game worker and serialize its gameplay input.
 class GameSession
 {
 public:
-    /// Create a session with a private data root and viewer URL.
+    /// Create a session with a fixed public ID and one-time control secret.
     GameSession(std::filesystem::path data_root,
                 std::filesystem::path runtime_dir,
-                std::string viewer_url);
+                std::string viewer_url,
+                std::string game_id,
+                std::string control_token,
+                std::string character_name,
+                std::string model_slug,
+                std::chrono::steady_clock::time_point created_at,
+                std::shared_ptr<GameRecordStore> records,
+                const ServerConfig& config);
 
     GameSession(const GameSession&) = delete;
     GameSession& operator=(const GameSession&) = delete;
 
-    /// Start one new NetHack worker and wait for its first boundary.
-    ToolResult newGame(const Json& arguments);
+    /// Start this game's worker and wait for its first boundary.
+    ToolResult startGame(const Json& arguments);
+
+    /// Authorize and refresh one game-specific agent call.
+    ToolResult authorize(const Json& arguments);
 
     /// Return the latest state, optionally waiting for a revision.
     ToolResult observe(const Json& arguments);
@@ -59,6 +73,24 @@ public:
     /// Stop any active worker during server shutdown.
     void shutdown();
 
+    /// Mark a game terminal when its idle or absolute deadline has passed.
+    bool expireIfNeeded();
+
+    /// Reap a completed worker and remove its private runtime directory.
+    bool cleanup();
+
+    /// Report whether this game has reached a terminal transition.
+    bool terminal() const;
+
+    /// Report whether the worker child is still running.
+    bool workerRunning() const;
+
+    /// Return the earlier idle or absolute lifetime deadline.
+    std::chrono::steady_clock::time_point nextDeadline() const;
+
+    /// Return the immutable UUID associated with this session.
+    const std::string& gameId() const;
+
     /// Return the configured spectator URL.
     const std::string& viewerUrl() const;
 
@@ -73,6 +105,8 @@ private:
     ToolResult sendInput(const Json& arguments, Json response,
                          const std::string& expected_kind);
     ToolResult errorResult(std::string code, std::string message) const;
+    ToolResult refreshAgentActivity(std::int64_t& activity_at_s);
+    bool persistAgentActivity(std::int64_t activity_at_s);
     void handleWorkerMessage(const Json& message);
     bool copyRuntimeFiles(const std::filesystem::path& run_directory,
                           std::string& error) const;
@@ -80,16 +114,40 @@ private:
                             const Json& state,
                             std::string& error);
     static bool parseKey(const Json& key, int& value, std::string& error);
-    static std::string makeGameId();
+    bool markTerminal(const std::string& reason,
+                      const std::string& end_time_kind,
+                      const std::string& lifecycle);
+    bool finishRecord(const std::string& reason,
+                      const std::string& end_time_kind,
+                      std::int64_t ended_at_s);
+    static std::int64_t wallClockSeconds();
 
     std::filesystem::path data_root_;
     std::filesystem::path runtime_dir_;
     std::string viewer_url_;
+    std::string game_id_;
+    std::string character_name_;
+    std::string model_slug_;
+    std::shared_ptr<GameRecordStore> records_;
+    std::chrono::steady_clock::time_point created_at_;
+    std::chrono::steady_clock::time_point last_agent_activity_;
+    std::chrono::seconds idle_timeout_;
+    std::chrono::seconds max_game_duration_;
+    std::array<unsigned char, 32> control_salt_{};
+    std::array<unsigned char, 32> control_digest_{};
+    std::size_t max_worker_output_bytes_;
+    std::size_t max_character_name_bytes_;
     ObservationStore observations_;
     std::unique_ptr<EngineProcess> process_;
-    mutable std::mutex session_mutex_;
     mutable std::mutex action_mutex_;
-    std::atomic<bool> stop_requested_ = false;
+    mutable std::mutex lifecycle_mutex_;
+    bool closing_ = false;
+    std::optional<std::string> end_reason_;
+    std::optional<std::string> end_time_kind_;
+    std::int64_t terminal_at_s_ = 0;
+    std::optional<std::string> terminal_result_;
+    std::optional<int> last_depth_;
+    std::atomic<bool> shutdown_requested_ = false;
     std::uint64_t operation_counter_ = 0;
 };
 
diff --git a/include/identity.hpp b/include/identity.hpp
new file mode 100644
index 0000000..dfa54eb
--- /dev/null
+++ b/include/identity.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <array>
+#include <string>
+#include <string_view>
+
+namespace nethack_mcp
+{
+
+/// Create a canonical UUIDv7 using the operating system random source.
+std::string makeGameId();
+
+/// Check the canonical lowercase UUIDv7 spelling used by public paths.
+bool validGameId(std::string_view game_id);
+
+/// Mint a one-time 256-bit bearer token encoded as base64url.
+std::string makeControlToken();
+
+/// Create a salted SHA-256 digest for an in-memory control token check.
+std::array<unsigned char, 32> hashControlToken(
+    const std::array<unsigned char, 32>& salt, std::string_view token);
+
+/// Compare token digests without data-dependent early exit.
+bool secureDigestEqual(const std::array<unsigned char, 32>& left,
+                       const std::array<unsigned char, 32>& right);
+
+/// Fill a fixed-size buffer from the operating system random source.
+bool secureRandom(void* destination, std::size_t size);
+
+} // namespace nethack_mcp
diff --git a/include/mcp_server.hpp b/include/mcp_server.hpp
index 9dee1b7..7f401cf 100644
--- a/include/mcp_server.hpp
+++ b/include/mcp_server.hpp
@@ -1,28 +1,35 @@
 #pragma once
 
-#include "game_session.hpp"
+#include "game_manager.hpp"
 
 namespace nethack_mcp
 {
 
-/// Dispatch MCP JSON-RPC messages for one game session.
+/// Dispatch MCP JSON-RPC messages across all active game sessions.
 class McpServer
 {
 public:
-    /// Bind the protocol dispatcher to a session.
-    explicit McpServer(GameSession& session);
+    /// Bind the protocol dispatcher to the shared game manager.
+    explicit McpServer(GameManager& manager);
 
     /// Dispatch one HTTP transport message.
     /// Set should_respond when the client expects a JSON-RPC reply.
-    Json handleMessage(const Json& request, bool& should_respond);
+    Json handleMessage(const Json& request, bool& should_respond,
+                       const std::string& client_id = {},
+                       bool modern_protocol = false);
 
 private:
+    Json handleMessageInternal(const Json& request, bool& should_respond,
+                               const std::string& client_id,
+                               bool modern_protocol);
+    Json addModernMetadata(Json response, const Json& request,
+                           bool modern_protocol) const;
     Json tools() const;
     Json toolResult(const ToolResult& result) const;
     Json jsonRpcError(const Json& id, int code,
                       const std::string& message) const;
 
-    GameSession& session_;
+    GameManager& manager_;
 };
 
 } // namespace nethack_mcp
diff --git a/include/server_config.hpp b/include/server_config.hpp
new file mode 100644
index 0000000..7191519
--- /dev/null
+++ b/include/server_config.hpp
@@ -0,0 +1,65 @@
+#pragma once
+
+#include <chrono>
+#include <cstddef>
+#include <filesystem>
+#include <string>
+#include <vector>
+
+namespace nethack_mcp
+{
+
+/// Startup-only limits and public URL policy for the service process.
+struct ServerConfig
+{
+    /// Loopback listener port.
+    int port = 8765;
+    /// Private per-game worker files, normally on temporary storage.
+    std::filesystem::path data_root;
+    /// Persistent SQLite database path, outside temporary storage.
+    std::filesystem::path database_path;
+    /// Canonical public URL used in MCP results and viewer links.
+    std::string public_base_url;
+    /// Exact Host header values accepted by the HTTP server.
+    std::vector<std::string> allowed_hosts;
+    /// Exact Origin header values accepted by the HTTP server.
+    std::vector<std::string> allowed_origins;
+    /// Proxy addresses permitted to supply the forwarded client address.
+    std::vector<std::string> trusted_proxy_addresses;
+    /// Maximum number of active and concurrently starting games.
+    std::size_t max_active_games = 8;
+    /// Game creation quota for each client address.
+    std::size_t new_games_per_client = 8;
+    /// Time window used for the per-client creation quota.
+    std::chrono::seconds new_game_rate_window{3600};
+    /// Bad control-token attempts allowed for each client address.
+    std::size_t control_failures_per_client = 10;
+    /// Time window used for the bad-token limit.
+    std::chrono::seconds control_failure_window{60};
+    /// Upper bound on client-address entries kept by rate limiters.
+    std::size_t max_rate_limit_clients = 4096;
+    /// Maximum number of simultaneous HTTP handler workers.
+    std::size_t max_concurrent_requests = 64;
+    /// Maximum active plus queued HTTP connection tasks.
+    std::size_t max_open_connections = 128;
+    /// Maximum concurrent spectator long polls.
+    std::size_t max_viewer_long_polls = 48;
+    /// End a game after this long without an accepted agent call.
+    std::chrono::seconds idle_timeout{600};
+    /// Maximum time from accepted creation to game termination.
+    std::chrono::seconds max_game_duration{86400};
+    /// Fallback maintenance cadence for expiry and cleanup work.
+    std::chrono::seconds lifecycle_sweep_interval{15};
+    /// Maximum duration of one browser state long poll.
+    std::chrono::seconds state_long_poll_timeout{15};
+    /// Maximum accepted MCP request body size in bytes.
+    std::size_t max_mcp_body_bytes = 1024U * 1024U;
+    /// Maximum diagnostic output bytes logged for one worker.
+    std::size_t max_worker_output_bytes = 1024U * 1024U;
+    /// Maximum accepted character name length in bytes.
+    std::size_t max_character_name_bytes = 30;
+    /// Maximum accepted model slug length in bytes.
+    std::size_t max_model_slug_bytes = 128;
+};
+
+} // namespace nethack_mcp
diff --git a/include/window_adapter.hpp b/include/window_adapter.hpp
index cbbf44b..0f04745 100644
--- a/include/window_adapter.hpp
+++ b/include/window_adapter.hpp
@@ -24,6 +24,9 @@ public:
     /// Install this adapter as the callback target for NetHack.
     void install();
 
+    /// Send the native NetHack terminal reason over the worker IPC channel.
+    static void reportTerminalResult(int how);
+
 private:
     struct MenuEntry
     {
diff --git a/src/engine_process.cpp b/src/engine_process.cpp
index d977f76..9578239 100644
--- a/src/engine_process.cpp
+++ b/src/engine_process.cpp
@@ -11,6 +11,7 @@
 #include <unistd.h>
 
 #include <array>
+#include <algorithm>
 #include <stdexcept>
 #include <vector>
 
@@ -25,7 +26,8 @@ EngineProcess::~EngineProcess()
 }
 
 bool EngineProcess::start(const Json& start_message, EventCallback callback,
-                          std::string& error)
+                          std::string& error,
+                          std::size_t max_diagnostic_bytes)
 {
     if(running_)
     {
@@ -97,6 +99,7 @@ bool EngineProcess::start(const Json& start_message, EventCallback callback,
     }
 
     process_id_ = child;
+    max_diagnostic_bytes_ = max_diagnostic_bytes;
     callback_ = std::move(callback);
     channel_ = std::make_unique<FramedChannel>(channel_fds[0]);
     running_ = true;
@@ -123,12 +126,12 @@ bool EngineProcess::send(const Json& message, std::string& error)
     return channel_->send(message, error);
 }
 
-void EngineProcess::terminate()
+void EngineProcess::terminate(bool force)
 {
     const pid_t process_id = process_id_;
     if(process_id > 0 && running_)
     {
-        ::kill(process_id, SIGTERM);
+        ::kill(process_id, force ? SIGKILL : SIGTERM);
     }
     if(channel_)
     {
@@ -188,6 +191,7 @@ void EngineProcess::readLoop()
 void EngineProcess::diagnosticLoop(int descriptor)
 {
     std::array<char, 4096> buffer{};
+    std::size_t logged_bytes = 0;
     while(true)
     {
         const ssize_t count = ::read(descriptor, buffer.data(), buffer.size());
@@ -203,8 +207,16 @@ void EngineProcess::diagnosticLoop(int descriptor)
             }
             break;
         }
-        std::fwrite(buffer.data(), 1, static_cast<std::size_t>(count), stderr);
-        std::fflush(stderr);
+        const std::size_t remaining = logged_bytes < max_diagnostic_bytes_
+            ? max_diagnostic_bytes_ - logged_bytes : 0;
+        const std::size_t bytes_to_log = std::min(
+            remaining, static_cast<std::size_t>(count));
+        if(bytes_to_log > 0)
+        {
+            std::fwrite(buffer.data(), 1, bytes_to_log, stderr);
+            std::fflush(stderr);
+            logged_bytes += bytes_to_log;
+        }
     }
     ::close(descriptor);
 }
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index c542296..5618315 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -1,10 +1,18 @@
 #include "game_http_server.hpp"
 
 #include "embedded_assets.hpp"
+#include "game_manager.hpp"
+#include "game_record_store.hpp"
 #include "game_session.hpp"
+#include "identity.hpp"
 #include "mcp_server.hpp"
 
+#include <algorithm>
 #include <chrono>
+#include <cstdint>
+#include <filesystem>
+#include <fstream>
+#include <sstream>
 #include <string>
 #include <string_view>
 
@@ -14,7 +22,106 @@ namespace nethack_mcp
 namespace
 {
 
-constexpr std::size_t MAX_MCP_MESSAGE_SIZE = 1024U * 1024U;
+class CounterSlot
+{
+public:
+    CounterSlot(std::atomic<std::size_t>& counter, std::size_t limit,
+                std::atomic<std::uint64_t>* request_count = nullptr,
+                std::atomic<std::uint64_t>* latency_total_us = nullptr)
+            : counter_(counter), request_count_(request_count),
+              latency_total_us_(latency_total_us),
+              started_at_(std::chrono::steady_clock::now())
+    {
+        const std::size_t previous = counter_.fetch_add(1);
+        admitted_ = previous < limit;
+        if(!admitted_)
+        {
+            counter_.fetch_sub(1);
+        }
+    }
+
+    ~CounterSlot()
+    {
+        if(admitted_)
+        {
+            counter_.fetch_sub(1);
+            if(request_count_ != nullptr && latency_total_us_ != nullptr)
+            {
+                const auto elapsed = std::chrono::duration_cast<
+                    std::chrono::microseconds>(
+                        std::chrono::steady_clock::now() - started_at_).count();
+                ++(*request_count_);
+                *latency_total_us_ += static_cast<std::uint64_t>(elapsed);
+            }
+        }
+    }
+
+    bool admitted() const
+    {
+        return admitted_;
+    }
+
+private:
+    std::atomic<std::size_t>& counter_;
+    std::atomic<std::uint64_t>* request_count_;
+    std::atomic<std::uint64_t>* latency_total_us_;
+    std::chrono::steady_clock::time_point started_at_;
+    bool admitted_ = false;
+};
+
+std::uint64_t processTaskCount()
+{
+    std::uint64_t count = 0;
+    std::error_code error;
+    for(std::filesystem::directory_iterator iterator("/proc/self/task", error),
+        end;
+        !error && iterator != end; iterator.increment(error))
+    {
+        ++count;
+    }
+    return count;
+}
+
+std::uint64_t processResidentBytes()
+{
+    std::ifstream status("/proc/self/status");
+    std::string line;
+    while(std::getline(status, line))
+    {
+        if(line.rfind("VmRSS:", 0) == 0)
+        {
+            std::istringstream fields(line.substr(6));
+            std::uint64_t kilobytes = 0;
+            fields >> kilobytes;
+            return kilobytes * 1024;
+        }
+    }
+    return 0;
+}
+
+std::filesystem::path processCgroupDirectory()
+{
+    std::ifstream cgroup("/proc/self/cgroup");
+    std::string line;
+    while(std::getline(cgroup, line))
+    {
+        if(line.rfind("0::", 0) == 0)
+        {
+            const std::filesystem::path relative =
+                std::filesystem::path(line.substr(3)).relative_path();
+            return std::filesystem::path("/sys/fs/cgroup") / relative;
+        }
+    }
+    return {};
+}
+
+std::uint64_t readUnsignedFile(const std::filesystem::path& path)
+{
+    std::ifstream input(path);
+    std::uint64_t value = 0;
+    input >> value;
+    return input ? value : 0;
+}
 
 const EmbeddedAsset* findAsset(std::string_view path)
 {
@@ -28,12 +135,70 @@ const EmbeddedAsset* findAsset(std::string_view path)
     return nullptr;
 }
 
+std::string htmlEscape(std::string_view text)
+{
+    std::string escaped;
+    escaped.reserve(text.size());
+    for(char value : text)
+    {
+        switch(value)
+        {
+        case '&': escaped += "&amp;"; break;
+        case '<': escaped += "&lt;"; break;
+        case '>': escaped += "&gt;"; break;
+        case '"': escaped += "&quot;"; break;
+        case '\'': escaped += "&#39;"; break;
+        default: escaped.push_back(value); break;
+        }
+    }
+    return escaped;
+}
+
+std::string timeElement(std::optional<std::int64_t> seconds)
+{
+    if(!seconds)
+    {
+        return "<span>Unknown</span>";
+    }
+    return "<time class=\"local-time\" data-unix=\""
+        + std::to_string(*seconds) + "\">" + std::to_string(*seconds)
+        + " UTC</time>";
+}
+
+void replaceTemplateValue(std::string& html, const std::string& marker,
+                          const std::string& value)
+{
+    std::size_t position = 0;
+    while((position = html.find(marker, position)) != std::string::npos)
+    {
+        html.replace(position, marker.size(), value);
+        position += value.size();
+    }
+}
+
+std::string endLabel(const GameRecord& record)
+{
+    if(record.end_time_kind == "recovery")
+    {
+        return "Recovered after interruption";
+    }
+    if(record.end_reason == "ascended")
+    {
+        return "Won (ascended)";
+    }
+    return record.end_reason.value_or("Unknown");
+}
+
+std::string depthText(std::optional<int> depth)
+{
+    return depth ? std::to_string(*depth) : "Unknown";
+}
+
 } // namespace
 
-GameHttpServer::GameHttpServer(GameSession& session, McpServer& mcp,
-                               int port)
-        : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", port}),
-          session_(session), mcp_(mcp), port_(port)
+GameHttpServer::GameHttpServer(GameManager& manager, McpServer& mcp)
+        : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", manager.config().port}),
+          manager_(manager), mcp_(mcp), config_(manager.config())
 {}
 
 GameHttpServer::~GameHttpServer()
@@ -44,10 +209,10 @@ GameHttpServer::~GameHttpServer()
 bool GameHttpServer::startServer(std::string& error)
 {
     setup();
-    if(!server.bind_to_port("127.0.0.1", port_))
+    if(!server.bind_to_port("127.0.0.1", config_.port))
     {
-        error = "could not bind HTTP server to 127.0.0.1:" +
-            std::to_string(port_);
+        error = "could not bind HTTP server to 127.0.0.1:"
+            + std::to_string(config_.port);
         return false;
     }
     started_ = true;
@@ -75,7 +240,14 @@ bool GameHttpServer::running() const
 
 void GameHttpServer::setup()
 {
-    server.set_payload_max_length(MAX_MCP_MESSAGE_SIZE);
+    server.set_payload_max_length(config_.max_mcp_body_bytes);
+    server.new_task_queue = [
+        max_threads = config_.max_concurrent_requests,
+        max_queued = config_.max_open_connections
+            - config_.max_concurrent_requests] {
+        return new httplib::ThreadPool(max_threads, max_threads, max_queued);
+    };
+    server.set_trusted_proxies(config_.trusted_proxy_addresses);
     server.Post("/mcp", [this](const Request& request,
                                 Response& response) {
         serveMcp(request, response);
@@ -91,6 +263,10 @@ void GameHttpServer::setup()
     server.Get("/", [this](const Request& request, Response& response) {
         servePage(request, response);
     });
+    server.Get(R"(/g/[0-9a-f-]{36})",
+               [this](const Request& request, Response& response) {
+                   serveGamePage(request, response);
+               });
     server.Get("/viewer.js", [this](const Request& request,
                                       Response& response) {
         serveScript(request, response);
@@ -103,22 +279,34 @@ void GameHttpServer::setup()
                                              Response& response) {
         serveFont(request, response);
     });
-    server.Get("/api/state", [this](const Request& request,
-                                      Response& response) {
-        serveState(request, response);
+    server.Get("/home.css", [this](const Request& request,
+                                     Response& response) {
+        serveStatic("/home.css", request, response);
     });
+    server.Get("/local-time.js", [this](const Request& request,
+                                          Response& response) {
+        serveStatic("/local_time.js", request, response);
+    });
+    server.Get(R"(/api/games/[0-9a-f-]{36}/state)",
+               [this](const Request& request, Response& response) {
+                   serveState(request, response);
+               });
     server.Get("/health", [this](const Request& request,
                                    Response& response) {
         serveHealth(request, response);
     });
+    server.Get("/metrics", [this](const Request& request,
+                                    Response& response) {
+        serveMetrics(request, response);
+    });
 }
 
 bool GameHttpServer::validHost(const Request& request) const
 {
     const std::string host = request.get_header_value("Host");
-    return host == "127.0.0.1" || host == "127.0.0.1:" +
-        std::to_string(port_) || host == "localhost" ||
-        host == "localhost:" + std::to_string(port_);
+    return std::find(config_.allowed_hosts.begin(),
+                     config_.allowed_hosts.end(), host)
+        != config_.allowed_hosts.end();
 }
 
 bool GameHttpServer::validOrigin(const Request& request) const
@@ -128,12 +316,28 @@ bool GameHttpServer::validOrigin(const Request& request) const
         return true;
     }
     const std::string origin = request.get_header_value("Origin");
-    return origin == "http://127.0.0.1:" + std::to_string(port_)
-        || origin == "http://localhost:" + std::to_string(port_);
+    return std::find(config_.allowed_origins.begin(),
+                     config_.allowed_origins.end(), origin)
+        != config_.allowed_origins.end();
+}
+
+void GameHttpServer::rejectRequest(Response& response) const
+{
+    response.status = 503;
+    response.set_header("Retry-After", "1");
+    response.set_header("Cache-Control", "no-store");
 }
 
 void GameHttpServer::serveMcp(const Request& request, Response& response)
 {
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    response.set_header("Cache-Control", "no-store");
     if(!validHost(request) || !validOrigin(request))
     {
         response.status = 403;
@@ -146,7 +350,7 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
         response.status = 415;
         return;
     }
-    if(request.body.size() > MAX_MCP_MESSAGE_SIZE)
+    if(request.body.size() > config_.max_mcp_body_bytes)
     {
         response.status = 413;
         return;
@@ -154,7 +358,8 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
     const std::string version = request.get_header_value(
         "MCP-Protocol-Version");
     if(!version.empty() && version != "2025-11-25"
-       && version != "2025-06-18" && version != "2025-03-26")
+       && version != "2025-06-18" && version != "2025-03-26"
+       && version != "2026-07-28")
     {
         response.status = 400;
         return;
@@ -165,14 +370,14 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
     {
         message = Json::parse(request.body);
     }
-    catch(const Json::parse_error& exception)
+    catch(const Json::parse_error&)
     {
         response.status = 400;
         response.set_content(Json({
             {"jsonrpc", "2.0"},
             {"id", nullptr},
             {"error", {
-                {"code", -32700}, {"message", exception.what()},
+                {"code", -32700}, {"message", "invalid JSON"},
             }},
         }).dump(), "application/json; charset=utf-8");
         return;
@@ -188,8 +393,74 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
         return;
     }
 
+    std::string body_version;
+    if(message.is_object() && message.contains("params")
+       && message.at("params").is_object()
+       && message.at("params").contains("_meta")
+       && message.at("params").at("_meta").is_object())
+    {
+        const Json& envelope = message.at("params").at("_meta");
+        const auto version_field = envelope.find(
+            "io.modelcontextprotocol/protocolVersion");
+        if(version_field != envelope.end() && version_field->is_string())
+        {
+            body_version = version_field->get<std::string>();
+        }
+    }
+    const bool modern_protocol = version == "2026-07-28"
+        || body_version == "2026-07-28";
+    if(modern_protocol)
+    {
+        const Json id = message.is_object() && message.contains("id")
+            ? message.at("id") : Json(nullptr);
+        const auto reject_header_mismatch = [&] {
+            response.status = 400;
+            response.set_content(Json({
+                {"jsonrpc", "2.0"},
+                {"id", id},
+                {"error", {
+                    {"code", -32020},
+                    {"message", "MCP routing headers do not match request"},
+                }},
+            }).dump(), "application/json; charset=utf-8");
+        };
+        if(version != "2026-07-28" || body_version != "2026-07-28"
+           || !message.is_object() || !message.contains("method")
+           || !message.at("method").is_string())
+        {
+            reject_header_mismatch();
+            return;
+        }
+        const std::string method = message.at("method").get<std::string>();
+        if(!request.has_header("Mcp-Method")
+           || request.get_header_value("Mcp-Method") != method)
+        {
+            reject_header_mismatch();
+            return;
+        }
+        if(method == "tools/call")
+        {
+            const Json params = message.value("params", Json::object());
+            if(!params.is_object() || !params.contains("name")
+               || !params.at("name").is_string()
+               || !request.has_header("Mcp-Name")
+               || request.get_header_value("Mcp-Name")
+                   != params.at("name").get<std::string>())
+            {
+                reject_header_mismatch();
+                return;
+            }
+        }
+        else if(request.has_header("Mcp-Name"))
+        {
+            reject_header_mismatch();
+            return;
+        }
+    }
+
     bool should_respond = true;
-    const Json reply = mcp_.handleMessage(message, should_respond);
+    const Json reply = mcp_.handleMessage(
+        message, should_respond, request.remote_addr, modern_protocol);
     if(!should_respond)
     {
         response.status = 202;
@@ -201,12 +472,20 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
 void GameHttpServer::rejectMcpStream(const Request& request,
                                       Response& response)
 {
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
     if(!validHost(request) || !validOrigin(request))
     {
         response.status = 403;
         return;
     }
     response.set_header("Allow", "POST");
+    response.set_header("Cache-Control", "no-store");
     response.status = 405;
 }
 
@@ -214,7 +493,14 @@ void GameHttpServer::serveStatic(std::string_view path,
                                   const Request& request,
                                   Response& response)
 {
-    if(!validHost(request))
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    if(!validHost(request) || !validOrigin(request))
     {
         response.status = 403;
         return;
@@ -225,13 +511,76 @@ void GameHttpServer::serveStatic(std::string_view path,
         response.status = 404;
         return;
     }
+    response.set_header("Cache-Control", "public, max-age=3600");
     response.set_content(asset->content.data(), asset->content.size(),
                          std::string(asset->content_type));
 }
 
 void GameHttpServer::servePage(const Request& request, Response& response)
 {
-    serveStatic("/", request, response);
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    if(!validHost(request) || !validOrigin(request))
+    {
+        response.status = 403;
+        return;
+    }
+    response.set_header("Cache-Control", "public, max-age=60");
+    response.set_content(recentPage(), "text/html; charset=utf-8");
+}
+
+void GameHttpServer::serveGamePage(const Request& request,
+                                   Response& response)
+{
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    if(!validHost(request) || !validOrigin(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const std::string game_id = request.path.substr(3);
+    if(!validGameId(game_id))
+    {
+        response.status = 404;
+        return;
+    }
+    if(manager_.findActive(game_id))
+    {
+        response.set_header("Cache-Control", "no-store");
+        const EmbeddedAsset* asset = findAsset("/");
+        if(asset == nullptr)
+        {
+            response.status = 500;
+            return;
+        }
+        response.set_content(asset->content.data(), asset->content.size(),
+                             "text/html; charset=utf-8");
+        return;
+    }
+    response.set_header("Cache-Control", "public, max-age=60");
+    auto record = manager_.getRecord(game_id);
+    if(!record)
+    {
+        response.status = 500;
+        return;
+    }
+    if(!*record || !(*record)->ended_at_s)
+    {
+        response.status = 404;
+        return;
+    }
+    response.set_content(recordPage(**record), "text/html; charset=utf-8");
 }
 
 void GameHttpServer::serveScript(const Request& request,
@@ -252,22 +601,64 @@ void GameHttpServer::serveFont(const Request& request, Response& response)
 
 void GameHttpServer::serveState(const Request& request, Response& response)
 {
-    if(!validHost(request))
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    response.set_header("Cache-Control", "no-store");
+    if(!validHost(request) || !validOrigin(request))
     {
         response.status = 403;
         return;
     }
-    Json state = session_.snapshot();
+    constexpr std::string_view PREFIX = "/api/games/";
+    constexpr std::string_view SUFFIX = "/state";
+    if(request.path.size() <= PREFIX.size() + SUFFIX.size())
+    {
+        response.status = 404;
+        return;
+    }
+    const std::string game_id = request.path.substr(
+        PREFIX.size(), request.path.size() - PREFIX.size() - SUFFIX.size());
+    if(!validGameId(game_id))
+    {
+        response.status = 404;
+        return;
+    }
+    auto session = manager_.findActive(game_id);
+    if(!session)
+    {
+        auto record = manager_.getRecord(game_id);
+        if(!record)
+        {
+            response.status = 500;
+            return;
+        }
+        response.status = *record && (*record)->ended_at_s ? 410 : 404;
+        return;
+    }
+
+    Json state = session->snapshot();
     const std::string requested_etag = request.get_header_value(
         "If-None-Match");
-    std::string etag = "\"" +
-        std::to_string(state.value("revision", 0ULL)) + "\"";
+    std::string etag = "\"" + game_id + ":"
+        + std::to_string(state.value("revision", 0ULL)) + "\"";
     if(requested_etag == etag)
     {
-        state = session_.waitForSnapshot(
-            state.value("revision", 0ULL), std::chrono::seconds(15));
-        etag = "\"" +
-            std::to_string(state.value("revision", 0ULL)) + "\"";
+        CounterSlot poll_slot(
+            viewer_long_polls_, config_.max_viewer_long_polls);
+        if(!poll_slot.admitted())
+        {
+            rejectRequest(response);
+            return;
+        }
+        state = session->waitForSnapshot(
+            state.value("revision", 0ULL), config_.state_long_poll_timeout);
+        etag = "\"" + game_id + ":"
+            + std::to_string(state.value("revision", 0ULL)) + "\"";
     }
     response.set_header("ETag", etag);
     if(requested_etag == etag)
@@ -280,17 +671,176 @@ void GameHttpServer::serveState(const Request& request, Response& response)
 
 void GameHttpServer::serveHealth(const Request& request, Response& response)
 {
-    if(!validHost(request))
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    if(!validHost(request) || !validOrigin(request))
     {
         response.status = 403;
         return;
     }
-    const Json state = session_.snapshot();
+    response.set_header("Cache-Control", "no-store");
     response.set_content(Json({
         {"ready", true},
-        {"lifecycle", state.value("lifecycle", "idle")},
-        {"viewer_url", session_.viewerUrl()},
+        {"public_base_url", manager_.publicBaseUrl()},
     }).dump(), "application/json; charset=utf-8");
 }
 
+void GameHttpServer::serveMetrics(const Request& request, Response& response)
+{
+    CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+                             &request_count_, &request_latency_total_us_);
+    if(!request_slot.admitted())
+    {
+        rejectRequest(response);
+        return;
+    }
+    if(!validHost(request) || !validOrigin(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const std::uint64_t worker_count = manager_.activeWorkerCount();
+    const std::uint64_t parent_task_count = processTaskCount();
+    const std::uint64_t request_count = request_count_.load();
+    const std::filesystem::path cgroup = processCgroupDirectory();
+    const double average_request_seconds = request_count == 0
+        ? 0.0
+        : static_cast<double>(request_latency_total_us_.load())
+            / static_cast<double>(request_count) / 1000000.0;
+    const std::string metrics =
+        "# HELP nethack_mcp_active_games Active game sessions.\n"
+        "# TYPE nethack_mcp_active_games gauge\n"
+        "nethack_mcp_active_games "
+        + std::to_string(manager_.activeGameCount()) + "\n"
+        "# HELP nethack_mcp_live_workers Running NetHack child processes.\n"
+        "# TYPE nethack_mcp_live_workers gauge\n"
+        "nethack_mcp_live_workers " + std::to_string(worker_count) + "\n"
+        "# HELP nethack_mcp_linux_tasks Estimated parent and worker tasks.\n"
+        "# TYPE nethack_mcp_linux_tasks gauge\n"
+        "nethack_mcp_linux_tasks "
+        + std::to_string(parent_task_count + worker_count) + "\n"
+        "# HELP nethack_mcp_parent_tasks Current server process tasks.\n"
+        "# TYPE nethack_mcp_parent_tasks gauge\n"
+        "nethack_mcp_parent_tasks " + std::to_string(parent_task_count) + "\n"
+        "# HELP nethack_mcp_cgroup_tasks Current cgroup task count.\n"
+        "# TYPE nethack_mcp_cgroup_tasks gauge\n"
+        "nethack_mcp_cgroup_tasks "
+        + std::to_string(cgroup.empty()
+              ? 0 : readUnsignedFile(cgroup / "pids.current")) + "\n"
+        "# HELP nethack_mcp_cgroup_memory_bytes Current cgroup memory use.\n"
+        "# TYPE nethack_mcp_cgroup_memory_bytes gauge\n"
+        "nethack_mcp_cgroup_memory_bytes "
+        + std::to_string(cgroup.empty()
+              ? 0 : readUnsignedFile(cgroup / "memory.current")) + "\n"
+        "# HELP nethack_mcp_process_resident_bytes Server process RSS.\n"
+        "# TYPE nethack_mcp_process_resident_bytes gauge\n"
+        "nethack_mcp_process_resident_bytes "
+        + std::to_string(processResidentBytes()) + "\n"
+        "# HELP nethack_mcp_runtime_bytes Current runtime directory size.\n"
+        "# TYPE nethack_mcp_runtime_bytes gauge\n"
+        "nethack_mcp_runtime_bytes "
+        + std::to_string(manager_.runtimeBytes()) + "\n"
+        "# HELP nethack_mcp_last_database_write_seconds Last SQLite write time.\n"
+        "# TYPE nethack_mcp_last_database_write_seconds gauge\n"
+        "nethack_mcp_last_database_write_seconds "
+        + std::to_string(static_cast<double>(
+              manager_.databaseWriteLatencyMicroseconds()) / 1000000.0) + "\n"
+        "# HELP nethack_mcp_expiry_cleanup_failures Failed cleanup attempts.\n"
+        "# TYPE nethack_mcp_expiry_cleanup_failures counter\n"
+        "nethack_mcp_expiry_cleanup_failures "
+        + std::to_string(manager_.expiryCleanupFailures()) + "\n"
+        "# HELP nethack_mcp_http_requests_total Completed HTTP handlers.\n"
+        "# TYPE nethack_mcp_http_requests_total counter\n"
+        "nethack_mcp_http_requests_total " + std::to_string(request_count) + "\n"
+        "# HELP nethack_mcp_http_request_latency_seconds Average handler time.\n"
+        "# TYPE nethack_mcp_http_request_latency_seconds gauge\n"
+        "nethack_mcp_http_request_latency_seconds "
+        + std::to_string(average_request_seconds) + "\n"
+        "# HELP nethack_mcp_viewer_long_polls Current viewer waits.\n"
+        "# TYPE nethack_mcp_viewer_long_polls gauge\n"
+        "nethack_mcp_viewer_long_polls "
+        + std::to_string(viewer_long_polls_.load()) + "\n"
+        "# HELP nethack_mcp_max_active_games Configured game capacity.\n"
+        "# TYPE nethack_mcp_max_active_games gauge\n"
+        "nethack_mcp_max_active_games "
+        + std::to_string(config_.max_active_games) + "\n"
+        "# HELP nethack_mcp_max_http_workers Configured handler worker limit.\n"
+        "# TYPE nethack_mcp_max_http_workers gauge\n"
+        "nethack_mcp_max_http_workers "
+        + std::to_string(config_.max_concurrent_requests) + "\n"
+        "# HELP nethack_mcp_max_viewer_long_polls Configured viewer wait limit.\n"
+        "# TYPE nethack_mcp_max_viewer_long_polls gauge\n"
+        "nethack_mcp_max_viewer_long_polls "
+        + std::to_string(config_.max_viewer_long_polls) + "\n";
+    response.set_header("Cache-Control", "no-store");
+    response.set_content(metrics, "text/plain; version=0.0.4; charset=utf-8");
+}
+
+std::string GameHttpServer::recentPage()
+{
+    auto records = manager_.recentGames();
+    if(!records)
+    {
+        return "<!doctype html><title>NetHack games</title><h1>"
+            "Records are temporarily unavailable.</h1>";
+    }
+    const EmbeddedAsset* asset = findAsset("/home.html");
+    if(asset == nullptr)
+    {
+        return "<!doctype html><title>NetHack games</title><h1>"
+            "Page template is unavailable.</h1>";
+    }
+    std::string rows;
+    if(records->empty())
+    {
+        rows = "<tr><td colspan=\"7\">No completed games yet.</td></tr>";
+    }
+    for(const GameRecord& record : *records)
+    {
+        rows += "<tr><td><a href=\"/g/" + htmlEscape(record.game_id) + "\">"
+            + htmlEscape(record.character_name) + "</a></td><td>"
+            + htmlEscape(record.model_slug) + "</td><td>"
+            + timeElement(record.started_at_s) + "</td><td>"
+            + timeElement(record.ended_at_s) + "</td><td>"
+            + htmlEscape(endLabel(record)) + "</td><td>"
+            + depthText(record.deepest_depth) + "</td><td>"
+            + depthText(record.last_depth) + "</td></tr>";
+    }
+    std::string html(asset->content);
+    replaceTemplateValue(html, "{{MCP_URL}}",
+                         htmlEscape(manager_.publicBaseUrl() + "mcp"));
+    replaceTemplateValue(html, "{{RECENT_GAMES}}", rows);
+    return html;
+}
+
+std::string GameHttpServer::recordPage(const GameRecord& record)
+{
+    const EmbeddedAsset* asset = findAsset("/record.html");
+    if(asset == nullptr)
+    {
+        return "<!doctype html><title>NetHack game</title><h1>"
+            "Page template is unavailable.</h1>";
+    }
+    std::string html(asset->content);
+    replaceTemplateValue(html, "{{CHARACTER_NAME}}",
+                         htmlEscape(record.character_name));
+    replaceTemplateValue(html, "{{GAME_ID}}", htmlEscape(record.game_id));
+    replaceTemplateValue(html, "{{MODEL_SLUG}}", htmlEscape(record.model_slug));
+    replaceTemplateValue(html, "{{STARTED_AT}}", timeElement(record.started_at_s));
+    replaceTemplateValue(html, "{{ENDED_AT}}", timeElement(record.ended_at_s));
+    replaceTemplateValue(html, "{{ENDED_TIME_LABEL}}",
+                         record.end_time_kind == "recovery"
+                             ? "Recovered at (actual end unknown)" : "Ended");
+    replaceTemplateValue(html, "{{END_LABEL}}", htmlEscape(endLabel(record)));
+    replaceTemplateValue(html, "{{DEEPEST_DEPTH}}",
+                         depthText(record.deepest_depth));
+    replaceTemplateValue(html, "{{LAST_DEPTH}}", depthText(record.last_depth));
+    return html;
+}
+
 } // namespace nethack_mcp
diff --git a/src/game_manager.cpp b/src/game_manager.cpp
new file mode 100644
index 0000000..c8b422f
--- /dev/null
+++ b/src/game_manager.cpp
@@ -0,0 +1,687 @@
+#include "game_manager.hpp"
+
+#include "identity.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cstdio>
+#include <filesystem>
+#include <string>
+#include <stdexcept>
+#include <unistd.h>
+#include <utility>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+std::int64_t wallClockSeconds()
+{
+    return std::chrono::duration_cast<std::chrono::seconds>(
+        std::chrono::system_clock::now().time_since_epoch()).count();
+}
+
+bool validPrintableAscii(const std::string& value)
+{
+    return std::all_of(value.begin(), value.end(), [](unsigned char byte) {
+        return byte >= 0x20 && byte <= 0x7e;
+    });
+}
+
+ToolResult managerError(std::string code, std::string message,
+                        Json value = Json::object())
+{
+    return {false, std::move(value), std::move(code), std::move(message)};
+}
+
+void preparePrivateDirectory(const std::filesystem::path& path)
+{
+    std::error_code error;
+    const bool already_exists = std::filesystem::exists(path, error);
+    if(error)
+    {
+        throw std::filesystem::filesystem_error(
+            "could not inspect private directory", path, error);
+    }
+    if(!already_exists)
+    {
+        std::filesystem::create_directories(path);
+        std::filesystem::permissions(
+            path, std::filesystem::perms::owner_all,
+            std::filesystem::perm_options::replace);
+    }
+
+    const auto status = std::filesystem::symlink_status(path);
+    if(std::filesystem::is_symlink(status)
+       || !std::filesystem::is_directory(status))
+    {
+        throw std::runtime_error("private data root must be a real directory");
+    }
+    constexpr auto SHARED_ACCESS = std::filesystem::perms::group_all
+        | std::filesystem::perms::others_all;
+    if((status.permissions() & SHARED_ACCESS) != std::filesystem::perms::none
+       || ::access(path.c_str(), W_OK | X_OK) != 0)
+    {
+        throw std::runtime_error(
+            "private data root must be writable and accessible only to its owner");
+    }
+}
+
+} // namespace
+
+mw::E<std::unique_ptr<GameManager>> GameManager::create(
+    ServerConfig config, std::filesystem::path runtime_source)
+{
+    auto opened = GameRecordStore::open(config.database_path);
+    if(!opened)
+    {
+        return std::unexpected(opened.error());
+    }
+    auto records = std::shared_ptr<GameRecordStore>(std::move(*opened));
+    auto recovered = records->recoverInterruptedGames(wallClockSeconds());
+    if(!recovered)
+    {
+        return std::unexpected(recovered.error());
+    }
+    try
+    {
+        auto manager = std::unique_ptr<GameManager>(new GameManager(
+            std::move(config), std::move(runtime_source), std::move(records)));
+        manager->removeOrphanedDirectories();
+        manager->sweep_thread_ = std::thread(&GameManager::sweepLoop,
+                                             manager.get());
+        return manager;
+    }
+    catch(const std::exception& exception)
+    {
+        return std::unexpected(mw::runtimeError(exception.what()));
+    }
+}
+
+GameManager::GameManager(ServerConfig config,
+                         std::filesystem::path runtime_source,
+                         std::shared_ptr<GameRecordStore> records)
+        : config_(std::move(config)), runtime_source_(std::move(runtime_source)),
+          records_(std::move(records))
+{
+    preparePrivateDirectory(config_.data_root);
+}
+
+GameManager::~GameManager()
+{
+    shutdown();
+}
+
+ToolResult GameManager::createGame(const Json& arguments,
+                                   const std::string& client_id)
+{
+    if(!arguments.contains("model_slug")
+       || !arguments.at("model_slug").is_string())
+    {
+        return managerError(
+            "INVALID_RESPONSE", "model_slug is required and must be a string");
+    }
+    const std::string model_slug =
+        arguments.at("model_slug").get<std::string>();
+    if(model_slug.empty() || model_slug.size() > config_.max_model_slug_bytes
+       || !validPrintableAscii(model_slug))
+    {
+        return managerError(
+            "INVALID_RESPONSE",
+            "model_slug must be 1 to 128 printable ASCII bytes");
+    }
+
+    if(arguments.contains("name") && !arguments.at("name").is_string())
+    {
+        return managerError("INVALID_RESPONSE", "name must be a string");
+    }
+    const std::string character_name = arguments.value(
+        "name", std::string("Agent"));
+    if(character_name.empty()
+       || character_name.size() > config_.max_character_name_bytes
+       || !validPrintableAscii(character_name))
+    {
+        return managerError(
+            "INVALID_RESPONSE",
+            "name must be printable ASCII and within the configured limit");
+    }
+
+    const auto now = std::chrono::steady_clock::now();
+    std::chrono::steady_clock::time_point accepted_at;
+    std::int64_t accepted_at_s = 0;
+    {
+        std::lock_guard registry_lock(registry_mutex_);
+        if(sessions_.size() + pending_creations_ >= config_.max_active_games)
+        {
+            return managerError(
+                "CAPACITY_REACHED", "active game capacity has been reached",
+                {{"retry_after_seconds",
+                  config_.lifecycle_sweep_interval.count()}});
+        }
+        if(!allowCreation(client_id, now))
+        {
+            return managerError(
+                "RATE_LIMITED", "new game creation rate limit reached",
+                {{"retry_after_seconds", config_.new_game_rate_window.count()}});
+        }
+        accepted_at = std::chrono::steady_clock::now();
+        accepted_at_s = wallClockSeconds();
+        ++pending_creations_;
+    }
+
+    std::string game_id;
+    std::string control_token;
+    bool record_inserted = false;
+    std::string error;
+    for(int attempt = 0; attempt < 8; ++attempt)
+    {
+        game_id = makeGameId();
+        control_token = makeControlToken();
+        if(game_id.empty() || control_token.empty())
+        {
+            error = "operating system random source failed";
+            break;
+        }
+        {
+            std::lock_guard registry_lock(registry_mutex_);
+            if(sessions_.contains(game_id) || reserved_ids_.contains(game_id))
+            {
+                game_id.clear();
+                continue;
+            }
+            reserved_ids_.insert(game_id);
+        }
+
+        GameRecord record;
+        record.game_id = game_id;
+        record.character_name = character_name;
+        record.model_slug = model_slug;
+        record.started_at_s = accepted_at_s;
+        record.last_activity_at_s = accepted_at_s;
+        auto inserted = records_->insertGame(record);
+        if(!inserted)
+        {
+            error = mw::errorMsg(inserted.error());
+            break;
+        }
+        if(*inserted)
+        {
+            record_inserted = true;
+            break;
+        }
+        {
+            std::lock_guard registry_lock(registry_mutex_);
+            reserved_ids_.erase(game_id);
+        }
+    }
+
+    if(!record_inserted)
+    {
+        releaseReservation(game_id);
+        if(error.empty())
+        {
+            error = "could not allocate a unique game identifier";
+        }
+        return managerError("RECORD_FAILURE", std::move(error));
+    }
+
+    const std::string viewer_url = config_.public_base_url + "g/" + game_id;
+    std::shared_ptr<GameSession> session;
+    try
+    {
+        session = std::make_shared<GameSession>(
+            config_.data_root, runtime_source_, viewer_url, game_id,
+            control_token, character_name, model_slug, accepted_at, records_,
+            config_);
+    }
+    catch(const std::exception& exception)
+    {
+        auto finished = records_->finishGame(
+            game_id, wallClockSeconds(), "observed", "failed");
+        [[maybe_unused]] const bool ignored = finished.has_value();
+        releaseReservation(game_id);
+        return managerError("ENGINE_FAILURE", exception.what());
+    }
+
+    ToolResult started;
+    try
+    {
+        started = session->startGame(arguments);
+    }
+    catch(const std::exception& exception)
+    {
+        auto finished = records_->finishGame(
+            game_id, wallClockSeconds(), "observed", "failed");
+        if(!finished)
+        {
+            std::fprintf(stderr, "could not record failed game %s: %s\n",
+                         game_id.c_str(), mw::errorMsg(finished.error()).c_str());
+        }
+        session->cleanup();
+        releaseReservation(game_id);
+        return managerError("ENGINE_FAILURE", exception.what());
+    }
+    if(!started.success)
+    {
+        auto finished = records_->finishGame(
+            game_id, wallClockSeconds(), "observed", "failed");
+        if(!finished)
+        {
+            std::fprintf(stderr, "could not record failed game %s: %s\n",
+                         game_id.c_str(), mw::errorMsg(finished.error()).c_str());
+        }
+        session->cleanup();
+        releaseReservation(game_id);
+        return started;
+    }
+
+    {
+        std::lock_guard registry_lock(registry_mutex_);
+        --pending_creations_;
+        reserved_ids_.erase(game_id);
+        sessions_.emplace(game_id, session);
+    }
+    sweep_condition_.notify_one();
+    return {
+        true,
+        {
+            {"game_id", game_id},
+            {"control_token", control_token},
+            {"viewer_url", viewer_url},
+            {"state", std::move(started.value)},
+        },
+        {},
+        {},
+    };
+}
+
+ToolResult GameManager::dispatch(const std::string& tool_name,
+                                const Json& arguments,
+                                const std::string& client_id)
+{
+    if(!arguments.contains("game_id") || !arguments.at("game_id").is_string())
+    {
+        return managerError("GAME_NOT_FOUND", "game was not found");
+    }
+    const std::string game_id = arguments.at("game_id").get<std::string>();
+    if(!validGameId(game_id))
+    {
+        return managerError("GAME_NOT_FOUND", "game was not found");
+    }
+    auto session = findActive(game_id);
+    if(!session)
+    {
+        return managerError("GAME_NOT_FOUND", "game was not found");
+    }
+
+    if(!allowControlAttempt(client_id))
+    {
+        return managerError(
+            "RATE_LIMITED", "too many invalid game control credentials",
+            {{"retry_after_seconds", config_.control_failure_window.count()}});
+    }
+    ToolResult authorized = session->authorize(arguments);
+    if(!authorized.success)
+    {
+        if(authorized.code == "FORBIDDEN")
+        {
+            recordControlFailure(client_id);
+        }
+        return authorized;
+    }
+    clearControlFailures(client_id);
+
+    if(tool_name == "observe") return session->observe(arguments);
+    if(tool_name == "press") return session->press(arguments);
+    if(tool_name == "select_menu") return session->selectMenu(arguments);
+    if(tool_name == "respond") return session->respond(arguments);
+    if(tool_name == "quit_game") return session->quitGame(arguments);
+    return managerError("UNKNOWN_TOOL", "unknown game tool");
+}
+
+std::shared_ptr<GameSession> GameManager::findActive(
+    const std::string& game_id) const
+{
+    std::lock_guard registry_lock(registry_mutex_);
+    const auto found = sessions_.find(game_id);
+    return found == sessions_.end() ? nullptr : found->second;
+}
+
+mw::E<std::optional<GameRecord>> GameManager::getRecord(
+    const std::string& game_id)
+{
+    if(!validGameId(game_id))
+    {
+        return std::optional<GameRecord>();
+    }
+    return records_->getGame(game_id);
+}
+
+mw::E<std::vector<GameRecord>> GameManager::recentGames()
+{
+    return records_->recentGames();
+}
+
+void GameManager::shutdown()
+{
+    if(stopping_.exchange(true))
+    {
+        return;
+    }
+    sweep_condition_.notify_all();
+    if(sweep_thread_.joinable())
+    {
+        sweep_thread_.join();
+    }
+    std::vector<std::shared_ptr<GameSession>> sessions;
+    {
+        std::lock_guard registry_lock(registry_mutex_);
+        sessions.reserve(sessions_.size());
+        for(const auto& [game_id, session] : sessions_)
+        {
+            [[maybe_unused]] const std::string& ignored = game_id;
+            sessions.push_back(session);
+        }
+    }
+    for(const auto& session : sessions)
+    {
+        session->shutdown();
+    }
+}
+
+const std::string& GameManager::publicBaseUrl() const
+{
+    return config_.public_base_url;
+}
+
+const ServerConfig& GameManager::config() const
+{
+    return config_;
+}
+
+std::size_t GameManager::activeGameCount() const
+{
+    std::lock_guard registry_lock(registry_mutex_);
+    return sessions_.size();
+}
+
+std::size_t GameManager::activeWorkerCount() const
+{
+    std::vector<std::shared_ptr<GameSession>> sessions;
+    {
+        std::lock_guard registry_lock(registry_mutex_);
+        sessions.reserve(sessions_.size());
+        for(const auto& [game_id, session] : sessions_)
+        {
+            [[maybe_unused]] const std::string& ignored = game_id;
+            sessions.push_back(session);
+        }
+    }
+    return static_cast<std::size_t>(std::count_if(
+        sessions.begin(), sessions.end(), [](const auto& session) {
+            return session->workerRunning();
+        }));
+}
+
+std::uint64_t GameManager::runtimeBytes() const
+{
+    std::uint64_t size = 0;
+    std::error_code error;
+    for(std::filesystem::recursive_directory_iterator iterator(
+            config_.data_root, error), end;
+        !error && iterator != end; iterator.increment(error))
+    {
+        if(iterator->is_regular_file(error))
+        {
+            size += iterator->file_size(error);
+        }
+        if(error)
+        {
+            error.clear();
+        }
+    }
+    return size;
+}
+
+std::uint64_t GameManager::expiryCleanupFailures() const
+{
+    return expiry_cleanup_failures_.load();
+}
+
+std::uint64_t GameManager::databaseWriteLatencyMicroseconds() const
+{
+    return records_->lastWriteLatencyMicroseconds();
+}
+
+void GameManager::sweepLoop()
+{
+    std::unique_lock wait_lock(sweep_mutex_);
+    while(!stopping_)
+    {
+        std::chrono::steady_clock::duration wait_duration =
+            config_.lifecycle_sweep_interval;
+        std::vector<std::shared_ptr<GameSession>> scheduled_sessions;
+        {
+            std::lock_guard registry_lock(registry_mutex_);
+            scheduled_sessions.reserve(sessions_.size());
+            for(const auto& [game_id, session] : sessions_)
+            {
+                [[maybe_unused]] const std::string& ignored = game_id;
+                scheduled_sessions.push_back(session);
+            }
+        }
+        const auto now = std::chrono::steady_clock::now();
+        for(const auto& session : scheduled_sessions)
+        {
+            const auto deadline = session->nextDeadline();
+            const auto until_deadline = deadline <= now
+                ? std::chrono::steady_clock::duration::zero()
+                : deadline - now;
+            wait_duration = std::min(wait_duration, until_deadline);
+        }
+        sweep_condition_.wait_for(wait_lock, wait_duration);
+        if(stopping_)
+        {
+            break;
+        }
+        wait_lock.unlock();
+
+        std::vector<std::pair<std::string, std::shared_ptr<GameSession>>> copy;
+        {
+            std::lock_guard registry_lock(registry_mutex_);
+            copy.reserve(sessions_.size());
+            for(const auto& item : sessions_)
+            {
+                copy.push_back(item);
+            }
+        }
+        for(const auto& [game_id, session] : copy)
+        {
+            session->expireIfNeeded();
+            if(session->terminal())
+            {
+                if(!session->cleanup())
+                {
+                    ++expiry_cleanup_failures_;
+                    continue;
+                }
+                std::lock_guard registry_lock(registry_mutex_);
+                const auto found = sessions_.find(game_id);
+                if(found != sessions_.end() && found->second == session)
+                {
+                    sessions_.erase(found);
+                }
+            }
+        }
+        removeOrphanedDirectories();
+        wait_lock.lock();
+    }
+}
+
+void GameManager::removeOrphanedDirectories()
+{
+    std::unordered_set<std::string> active_ids;
+    {
+        std::lock_guard registry_lock(registry_mutex_);
+        active_ids.reserve(sessions_.size() + reserved_ids_.size());
+        for(const auto& [game_id, session] : sessions_)
+        {
+            [[maybe_unused]] const std::shared_ptr<GameSession>& ignored =
+                session;
+            active_ids.insert(game_id);
+        }
+        active_ids.insert(reserved_ids_.begin(), reserved_ids_.end());
+    }
+    std::error_code error;
+    if(!std::filesystem::exists(config_.data_root, error))
+    {
+        return;
+    }
+    for(std::filesystem::directory_iterator iterator(config_.data_root, error),
+        end;
+        !error && iterator != end; iterator.increment(error))
+    {
+        if(!iterator->is_directory(error))
+        {
+            continue;
+        }
+        const std::string game_id = iterator->path().filename().string();
+        if(!validGameId(game_id) || active_ids.contains(game_id))
+        {
+            continue;
+        }
+        std::error_code remove_error;
+        std::filesystem::remove_all(iterator->path(), remove_error);
+        if(remove_error)
+        {
+            ++expiry_cleanup_failures_;
+            std::fprintf(stderr, "could not remove orphaned game directory %s: %s\n",
+                         iterator->path().c_str(), remove_error.message().c_str());
+        }
+    }
+    if(error)
+    {
+        std::fprintf(stderr, "could not scan game data root: %s\n",
+                     error.message().c_str());
+    }
+}
+
+void GameManager::releaseReservation(const std::string& game_id)
+{
+    std::lock_guard registry_lock(registry_mutex_);
+    if(pending_creations_ > 0)
+    {
+        --pending_creations_;
+    }
+    reserved_ids_.erase(game_id);
+}
+
+bool GameManager::allowCreation(
+    const std::string& client_id,
+    std::chrono::steady_clock::time_point now)
+{
+    const std::string key = client_id.empty() ? "unknown" : client_id;
+    pruneClientHistory(now);
+    auto found = client_history_.find(key);
+    if(found == client_history_.end())
+    {
+        if(client_history_.size() >= config_.max_rate_limit_clients)
+        {
+            return false;
+        }
+        found = client_history_.try_emplace(key).first;
+    }
+    auto& timestamps = found->second.creation_times;
+    if(timestamps.size() >= config_.new_games_per_client)
+    {
+        return false;
+    }
+    timestamps.push_back(now);
+    return true;
+}
+
+bool GameManager::allowControlAttempt(const std::string& client_id)
+{
+    std::lock_guard registry_lock(registry_mutex_);
+    const auto now = std::chrono::steady_clock::now();
+    pruneClientHistory(now);
+    const std::string key = client_id.empty() ? "unknown" : client_id;
+    auto found = client_history_.find(key);
+    if(found == client_history_.end())
+    {
+        if(client_history_.size() >= config_.max_rate_limit_clients)
+        {
+            return false;
+        }
+        found = client_history_.try_emplace(key).first;
+    }
+    return found->second.auth_failures.size()
+        < config_.control_failures_per_client;
+}
+
+void GameManager::recordControlFailure(const std::string& client_id)
+{
+    std::lock_guard registry_lock(registry_mutex_);
+    const auto now = std::chrono::steady_clock::now();
+    pruneClientHistory(now);
+    const std::string key = client_id.empty() ? "unknown" : client_id;
+    auto found = client_history_.find(key);
+    if(found == client_history_.end())
+    {
+        if(client_history_.size() >= config_.max_rate_limit_clients)
+        {
+            return;
+        }
+        found = client_history_.try_emplace(key).first;
+    }
+    found->second.auth_failures.push_back(now);
+}
+
+void GameManager::clearControlFailures(const std::string& client_id)
+{
+    std::lock_guard registry_lock(registry_mutex_);
+    const std::string key = client_id.empty() ? "unknown" : client_id;
+    auto found = client_history_.find(key);
+    if(found != client_history_.end())
+    {
+        found->second.auth_failures.clear();
+        if(found->second.creation_times.empty())
+        {
+            client_history_.erase(found);
+        }
+    }
+}
+
+void GameManager::pruneClientHistory(
+    std::chrono::steady_clock::time_point now)
+{
+    const auto creation_cutoff = now - config_.new_game_rate_window;
+    const auto auth_cutoff = now - config_.control_failure_window;
+    for(auto iterator = client_history_.begin();
+        iterator != client_history_.end();)
+    {
+        auto& history = iterator->second;
+        while(!history.creation_times.empty()
+              && history.creation_times.front() <= creation_cutoff)
+        {
+            history.creation_times.pop_front();
+        }
+        while(!history.auth_failures.empty()
+              && history.auth_failures.front() <= auth_cutoff)
+        {
+            history.auth_failures.pop_front();
+        }
+        if(history.creation_times.empty() && history.auth_failures.empty())
+        {
+            iterator = client_history_.erase(iterator);
+        }
+        else
+        {
+            ++iterator;
+        }
+    }
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_record_store.cpp b/src/game_record_store.cpp
new file mode 100644
index 0000000..9d99046
--- /dev/null
+++ b/src/game_record_store.cpp
@@ -0,0 +1,450 @@
+#include "game_record_store.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include <unistd.h>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+using RecordRow = std::tuple<
+    std::string, std::string, std::string, std::int64_t, std::int64_t,
+    std::optional<std::int64_t>, std::optional<std::string>,
+    std::optional<std::string>, std::optional<int>, std::optional<int>>;
+
+class WriteTimer
+{
+public:
+    explicit WriteTimer(std::atomic<std::uint64_t>& latency)
+            : latency_(latency), started_at_(std::chrono::steady_clock::now())
+    {}
+
+    ~WriteTimer()
+    {
+        latency_ = static_cast<std::uint64_t>(
+            std::chrono::duration_cast<std::chrono::microseconds>(
+                std::chrono::steady_clock::now() - started_at_).count());
+    }
+
+private:
+    std::atomic<std::uint64_t>& latency_;
+    std::chrono::steady_clock::time_point started_at_;
+};
+
+GameRecord makeRecord(const RecordRow& row)
+{
+    return {
+        std::get<0>(row), std::get<1>(row), std::get<2>(row),
+        std::get<3>(row), std::get<4>(row), std::get<5>(row),
+        std::get<6>(row), std::get<7>(row), std::get<8>(row),
+        std::get<9>(row),
+    };
+}
+
+std::string recordSelect()
+{
+    return "SELECT game_id, character_name, model_slug, started_at_s, "
+        "last_activity_at_s, ended_at_s, end_time_kind, end_reason, "
+        "last_depth, deepest_depth FROM game_records ";
+}
+
+std::filesystem::path prepareDatabaseDirectory(
+    const std::filesystem::path& database_path)
+{
+    const std::filesystem::path absolute_path =
+        std::filesystem::absolute(database_path);
+    const std::filesystem::path parent = absolute_path.parent_path();
+    std::error_code error;
+    const bool already_exists = std::filesystem::exists(parent, error);
+    if(error)
+    {
+        throw std::filesystem::filesystem_error(
+            "could not inspect database directory", parent, error);
+    }
+    if(!already_exists)
+    {
+        std::filesystem::create_directories(parent);
+        std::filesystem::permissions(
+            parent, std::filesystem::perms::owner_all,
+            std::filesystem::perm_options::replace);
+    }
+
+    const auto status = std::filesystem::symlink_status(parent);
+    if(std::filesystem::is_symlink(status)
+       || !std::filesystem::is_directory(status))
+    {
+        throw std::runtime_error(
+            "database parent must be a real directory");
+    }
+    constexpr auto SHARED_ACCESS = std::filesystem::perms::group_all
+        | std::filesystem::perms::others_all;
+    if((status.permissions() & SHARED_ACCESS) != std::filesystem::perms::none
+       || ::access(parent.c_str(), W_OK | X_OK) != 0)
+    {
+        throw std::runtime_error(
+            "database parent must be writable and accessible only to its owner");
+    }
+
+    const bool database_exists = std::filesystem::exists(absolute_path, error);
+    if(error)
+    {
+        throw std::filesystem::filesystem_error(
+            "could not inspect database file", absolute_path, error);
+    }
+    if(database_exists)
+    {
+        const auto database_status =
+            std::filesystem::symlink_status(absolute_path);
+        if(std::filesystem::is_symlink(database_status)
+           || !std::filesystem::is_regular_file(database_status))
+        {
+            throw std::runtime_error(
+                "database path must be a regular file, not a symlink");
+        }
+    }
+    return absolute_path;
+}
+
+} // namespace
+
+Json GameRecord::toJson() const
+{
+    return {
+        {"game_id", game_id},
+        {"character_name", character_name},
+        {"model_slug", model_slug},
+        {"started_at_s", started_at_s},
+        {"last_activity_at_s", last_activity_at_s},
+        {"ended_at_s", ended_at_s ? Json(*ended_at_s) : Json(nullptr)},
+        {"end_time_kind", end_time_kind ? Json(*end_time_kind) : Json(nullptr)},
+        {"end_reason", end_reason ? Json(*end_reason) : Json(nullptr)},
+        {"last_depth", last_depth ? Json(*last_depth) : Json(nullptr)},
+        {"deepest_depth", deepest_depth ? Json(*deepest_depth) : Json(nullptr)},
+    };
+}
+
+GameRecordStore::GameRecordStore(std::unique_ptr<mw::SQLite> database)
+        : database_(std::move(database))
+{}
+
+mw::E<std::unique_ptr<GameRecordStore>> GameRecordStore::open(
+    const std::filesystem::path& database_path)
+{
+    std::filesystem::path absolute_database_path;
+    try
+    {
+        absolute_database_path = prepareDatabaseDirectory(database_path);
+    }
+    catch(const std::exception& exception)
+    {
+        return std::unexpected(mw::runtimeError(exception.what()));
+    }
+
+    auto database = mw::SQLite::connectFile(
+        absolute_database_path.string(), 5000);
+    if(!database)
+    {
+        return std::unexpected(database.error());
+    }
+    auto store = std::unique_ptr<GameRecordStore>(
+        new GameRecordStore(std::move(*database)));
+    auto migrated = store->migrate();
+    if(!migrated)
+    {
+        return std::unexpected(migrated.error());
+    }
+
+    try
+    {
+        std::filesystem::permissions(
+            absolute_database_path,
+            std::filesystem::perms::owner_read
+                | std::filesystem::perms::owner_write,
+            std::filesystem::perm_options::replace);
+    }
+    catch(const std::exception& exception)
+    {
+        return std::unexpected(mw::runtimeError(exception.what()));
+    }
+    return store;
+}
+
+mw::E<void> GameRecordStore::migrate()
+{
+    std::lock_guard lock(mutex_);
+    auto version = database_->evalToValue<int>("PRAGMA user_version;");
+    if(!version)
+    {
+        return std::unexpected(version.error());
+    }
+    if(*version > 1)
+    {
+        return std::unexpected(mw::runtimeError(
+            "game database schema is newer than this server"));
+    }
+    if(*version == 1)
+    {
+        return {};
+    }
+    auto begun = database_->execute("BEGIN IMMEDIATE;");
+    if(!begun)
+    {
+        return std::unexpected(begun.error());
+    }
+    auto table = database_->execute(
+        "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))"
+        ");");
+    if(!table)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(table.error());
+    }
+    auto index = database_->execute(
+        "CREATE INDEX game_records_recent_idx "
+        "ON game_records (ended_at_s DESC, game_id DESC) "
+        "WHERE ended_at_s IS NOT NULL;");
+    if(!index)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(index.error());
+    }
+    auto migration = database_->execute("PRAGMA user_version = 1;");
+    if(!migration)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(migration.error());
+    }
+    return database_->execute("COMMIT;");
+}
+
+mw::E<bool> GameRecordStore::insertGame(const GameRecord& record)
+{
+    WriteTimer timer(last_write_latency_us_);
+    std::lock_guard lock(mutex_);
+    auto statement = database_->statementFromStr(
+        "INSERT OR IGNORE INTO game_records "
+        "(game_id, character_name, model_slug, started_at_s, "
+        "last_activity_at_s) VALUES (?, ?, ?, ?, ?);");
+    if(!statement)
+    {
+        return std::unexpected(statement.error());
+    }
+    auto bound = statement->bind(
+        record.game_id, record.character_name, record.model_slug,
+        record.started_at_s, record.last_activity_at_s);
+    if(!bound)
+    {
+        return std::unexpected(bound.error());
+    }
+    auto inserted = database_->execute(std::move(*statement));
+    if(!inserted)
+    {
+        return std::unexpected(inserted.error());
+    }
+    return database_->changedRowsCount() == 1;
+}
+
+mw::E<void> GameRecordStore::updateLocation(
+    const std::string& game_id, int depth)
+{
+    WriteTimer timer(last_write_latency_us_);
+    std::lock_guard lock(mutex_);
+    auto statement = database_->statementFromStr(
+        "UPDATE game_records SET last_depth = ?, "
+        "deepest_depth = CASE WHEN deepest_depth IS NULL OR deepest_depth < ? "
+        "THEN ? ELSE deepest_depth END "
+        "WHERE game_id = ? AND ended_at_s IS NULL;");
+    if(!statement)
+    {
+        return std::unexpected(statement.error());
+    }
+    auto bound = statement->bind(depth, depth, depth, game_id);
+    if(!bound)
+    {
+        return std::unexpected(bound.error());
+    }
+    return database_->execute(std::move(*statement));
+}
+
+mw::E<void> GameRecordStore::updateActivity(
+    const std::string& game_id, std::int64_t activity_at_s)
+{
+    WriteTimer timer(last_write_latency_us_);
+    std::lock_guard lock(mutex_);
+    auto statement = database_->statementFromStr(
+        "UPDATE game_records SET last_activity_at_s = ? "
+        "WHERE game_id = ? AND ended_at_s IS NULL;");
+    if(!statement)
+    {
+        return std::unexpected(statement.error());
+    }
+    auto bound = statement->bind(activity_at_s, game_id);
+    if(!bound)
+    {
+        return std::unexpected(bound.error());
+    }
+    return database_->execute(std::move(*statement));
+}
+
+mw::E<bool> GameRecordStore::finishGame(
+    const std::string& game_id, std::int64_t ended_at_s,
+    const std::string& end_time_kind, const std::string& end_reason)
+{
+    WriteTimer timer(last_write_latency_us_);
+    std::lock_guard lock(mutex_);
+    auto begun = database_->execute("BEGIN IMMEDIATE;");
+    if(!begun)
+    {
+        return std::unexpected(begun.error());
+    }
+    auto statement = database_->statementFromStr(
+        "UPDATE game_records SET ended_at_s = ?, end_time_kind = ?, "
+        "end_reason = ? WHERE game_id = ? AND ended_at_s IS NULL;");
+    if(!statement)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(statement.error());
+    }
+    auto bound = statement->bind(
+        ended_at_s, end_time_kind, end_reason, game_id);
+    if(!bound)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(bound.error());
+    }
+    auto updated = database_->execute(std::move(*statement));
+    if(!updated)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(updated.error());
+    }
+    const bool won = database_->changedRowsCount() == 1;
+    auto committed = database_->execute("COMMIT;");
+    if(!committed)
+    {
+        return std::unexpected(committed.error());
+    }
+    return won;
+}
+
+mw::E<std::vector<GameRecord>> GameRecordStore::recentGames()
+{
+    std::lock_guard lock(mutex_);
+    auto statement = database_->statementFromStr(
+        recordSelect() + "WHERE ended_at_s IS NOT NULL "
+        "ORDER BY ended_at_s DESC, game_id DESC LIMIT 10;");
+    if(!statement)
+    {
+        return std::unexpected(statement.error());
+    }
+    auto rows = database_->eval<
+        std::string, std::string, std::string, std::int64_t, std::int64_t,
+        std::optional<std::int64_t>, std::optional<std::string>,
+        std::optional<std::string>, std::optional<int>, std::optional<int>>(
+            std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(rows.error());
+    }
+    std::vector<GameRecord> records;
+    records.reserve(rows->size());
+    for(const RecordRow& row : *rows)
+    {
+        records.push_back(makeRecord(row));
+    }
+    return records;
+}
+
+mw::E<std::optional<GameRecord>> GameRecordStore::getGame(
+    const std::string& game_id)
+{
+    std::lock_guard lock(mutex_);
+    auto statement = database_->statementFromStr(
+        recordSelect() + "WHERE game_id = ? LIMIT 1;");
+    if(!statement)
+    {
+        return std::unexpected(statement.error());
+    }
+    auto bound = statement->bind(game_id);
+    if(!bound)
+    {
+        return std::unexpected(bound.error());
+    }
+    auto rows = database_->eval<
+        std::string, std::string, std::string, std::int64_t, std::int64_t,
+        std::optional<std::int64_t>, std::optional<std::string>,
+        std::optional<std::string>, std::optional<int>, std::optional<int>>(
+            std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(rows.error());
+    }
+    if(rows->empty())
+    {
+        return std::optional<GameRecord>();
+    }
+    return std::optional<GameRecord>(makeRecord(rows->front()));
+}
+
+mw::E<void> GameRecordStore::recoverInterruptedGames(
+    std::int64_t recovery_time_s)
+{
+    WriteTimer timer(last_write_latency_us_);
+    std::lock_guard lock(mutex_);
+    auto begun = database_->execute("BEGIN IMMEDIATE;");
+    if(!begun)
+    {
+        return std::unexpected(begun.error());
+    }
+    auto statement = database_->statementFromStr(
+        "UPDATE game_records SET ended_at_s = ?, end_time_kind = 'recovery', "
+        "end_reason = 'interrupted' WHERE ended_at_s IS NULL;");
+    if(!statement)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(statement.error());
+    }
+    auto bound = statement->bind(recovery_time_s);
+    if(!bound)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(bound.error());
+    }
+    auto updated = database_->execute(std::move(*statement));
+    if(!updated)
+    {
+        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+        return std::unexpected(updated.error());
+    }
+    return database_->execute("COMMIT;");
+}
+
+std::uint64_t GameRecordStore::lastWriteLatencyMicroseconds() const
+{
+    return last_write_latency_us_.load();
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_session.cpp b/src/game_session.cpp
index 0e95375..ceb258e 100644
--- a/src/game_session.cpp
+++ b/src/game_session.cpp
@@ -1,11 +1,13 @@
 #include "game_session.hpp"
 
+#include "identity.hpp"
+
 #include <algorithm>
 #include <chrono>
 #include <cctype>
+#include <cstdio>
 #include <cstring>
 #include <fstream>
-#include <random>
 #include <sstream>
 #include <utility>
 
@@ -113,33 +115,40 @@ bool resolveCharacter(const Json& arguments, Json& character,
 
 GameSession::GameSession(std::filesystem::path data_root,
                          std::filesystem::path runtime_dir,
-                         std::string viewer_url)
+                         std::string viewer_url,
+                         std::string game_id,
+                         std::string control_token,
+                         std::string character_name,
+                         std::string model_slug,
+                         Clock::time_point created_at,
+                         std::shared_ptr<GameRecordStore> records,
+                         const ServerConfig& config)
         : data_root_(std::move(data_root)), runtime_dir_(std::move(runtime_dir)),
-          viewer_url_(std::move(viewer_url)), observations_(viewer_url_)
+          viewer_url_(std::move(viewer_url)), game_id_(std::move(game_id)),
+          character_name_(std::move(character_name)),
+          model_slug_(std::move(model_slug)), records_(std::move(records)),
+          created_at_(created_at), last_agent_activity_(created_at_),
+          idle_timeout_(config.idle_timeout),
+          max_game_duration_(config.max_game_duration),
+          max_worker_output_bytes_(config.max_worker_output_bytes),
+          max_character_name_bytes_(config.max_character_name_bytes),
+          observations_(viewer_url_)
 {
-    std::filesystem::create_directories(data_root_);
+    if(!secureRandom(control_salt_.data(), control_salt_.size()))
+    {
+        throw std::runtime_error("operating system random source failed");
+    }
+    control_digest_ = hashControlToken(control_salt_, control_token);
 }
 
-ToolResult GameSession::newGame(const Json& arguments)
+ToolResult GameSession::startGame(const Json& arguments)
 {
     std::unique_lock action_lock(action_mutex_);
-    {
-        std::lock_guard session_lock(session_mutex_);
-        if(process_ && process_->running())
-        {
-            return errorResult("GAME_ACTIVE", "a game is already active");
-        }
-        if(process_)
-        {
-            process_.reset();
-        }
-    }
-
     std::string name = jsonString(arguments, "name", "Agent");
-    if(name.empty() || name.size() > 30)
+    if(name.empty() || name.size() > max_character_name_bytes_)
     {
         return errorResult("INVALID_RESPONSE",
-                           "name must contain between 1 and 30 bytes");
+                           "name has an invalid byte length");
     }
     for(unsigned char character : name)
     {
@@ -168,8 +177,7 @@ ToolResult GameSession::newGame(const Json& arguments)
     }
 #endif
 
-    const std::string game_id = makeGameId();
-    const std::filesystem::path run_directory = data_root_ / game_id;
+    const std::filesystem::path run_directory = data_root_ / game_id_;
     std::string error;
     if(!copyRuntimeFiles(run_directory, error))
     {
@@ -177,7 +185,7 @@ ToolResult GameSession::newGame(const Json& arguments)
     }
 
     Json starting = observations_.snapshot();
-    starting["game_id"] = game_id;
+    starting["game_id"] = game_id_;
     starting["lifecycle"] = "starting";
     starting["pending"] = nullptr;
     starting["operation"] = nullptr;
@@ -185,16 +193,12 @@ ToolResult GameSession::newGame(const Json& arguments)
     const std::uint64_t starting_revision = observations_.publish(
         std::move(starting));
 
-    stop_requested_ = false;
-    {
-        std::lock_guard session_lock(session_mutex_);
-        process_ = std::make_unique<EngineProcess>();
-    }
+    process_ = std::make_unique<EngineProcess>();
 
     Json start_message = {
         {"type", "start"},
         {"ipc_version", IPC_VERSION},
-        {"game_id", game_id},
+        {"game_id", game_id_},
         {"run_dir", run_directory.string()},
         {"name", name},
         {"character", character},
@@ -205,7 +209,7 @@ ToolResult GameSession::newGame(const Json& arguments)
            [this](const Json& message) {
                handleWorkerMessage(message);
            },
-           spawn_error))
+           spawn_error, max_worker_output_bytes_))
     {
         Json failed = observations_.snapshot();
         failed["lifecycle"] = "failed";
@@ -216,14 +220,81 @@ ToolResult GameSession::newGame(const Json& arguments)
 
     Json state = observations_.waitForRevision(
         starting_revision, std::chrono::seconds(10));
-    if(state.value("game_id", "") != game_id)
+    if(state.value("game_id", "") != game_id_)
     {
         return errorResult("ENGINE_FAILURE",
                            "worker returned a different game identifier");
     }
+    if(state.value("lifecycle", "") == "failed")
+    {
+        return errorResult("ENGINE_FAILURE",
+                           "NetHack worker failed during startup");
+    }
     return {true, std::move(state), {}, {}};
 }
 
+ToolResult GameSession::authorize(const Json& arguments)
+{
+    if(!arguments.contains("game_id") || !arguments.at("game_id").is_string()
+       || arguments.at("game_id").get<std::string>() != game_id_)
+    {
+        return errorResult("GAME_NOT_FOUND", "game was not found");
+    }
+    if(!arguments.contains("control_token")
+       || !arguments.at("control_token").is_string())
+    {
+        return errorResult("FORBIDDEN", "game control credentials are invalid");
+    }
+
+    const std::string token = arguments.at("control_token").get<std::string>();
+    if(token.size() != 43)
+    {
+        return errorResult("FORBIDDEN", "game control credentials are invalid");
+    }
+    for(char character : token)
+    {
+        const bool valid = (character >= 'A' && character <= 'Z')
+            || (character >= 'a' && character <= 'z')
+            || (character >= '0' && character <= '9')
+            || character == '-' || character == '_';
+        if(!valid)
+        {
+            return errorResult(
+                "FORBIDDEN", "game control credentials are invalid");
+        }
+    }
+    const auto digest = hashControlToken(control_salt_, token);
+    if(!secureDigestEqual(digest, control_digest_))
+    {
+        return errorResult("FORBIDDEN", "game control credentials are invalid");
+    }
+
+    std::string expired_reason;
+    const auto now = Clock::now();
+    {
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        if(closing_)
+        {
+            return errorResult("GAME_CLOSING", "game cleanup has begun");
+        }
+        if(now >= created_at_ + max_game_duration_)
+        {
+            expired_reason = "time_limit";
+        }
+        else if(now >= last_agent_activity_ + idle_timeout_)
+        {
+            expired_reason = "idle_timeout";
+        }
+    }
+    if(!expired_reason.empty())
+    {
+        expireIfNeeded();
+        return errorResult("GAME_CLOSING", "game lifetime has ended");
+    }
+
+    return {true, Json::object(), {}, {}};
+}
+
 ToolResult GameSession::observe(const Json& arguments)
 {
     const Json state = observations_.snapshot();
@@ -234,6 +305,14 @@ ToolResult GameSession::observe(const Json& arguments)
         return errorResult("STALE_GAME", error);
     }
 
+    if(arguments.contains("detail")
+       && (!arguments.at("detail").is_string()
+           || (arguments.at("detail") != "compact"
+               && arguments.at("detail") != "full")))
+    {
+        return errorResult("INVALID_RESPONSE", "detail is invalid");
+    }
+
     int wait_ms = 0;
     if(arguments.contains("wait_ms"))
     {
@@ -249,26 +328,45 @@ ToolResult GameSession::observe(const Json& arguments)
         }
     }
 
+    std::optional<std::uint64_t> after_message_id;
+    if(arguments.contains("after_message_id"))
+    {
+        if(!arguments.at("after_message_id").is_number_integer()
+           || arguments.at("after_message_id").get<long long>() < 0)
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "after_message_id must be a nonnegative integer");
+        }
+        after_message_id = arguments.at("after_message_id").get<
+            std::uint64_t>();
+    }
+
+    std::int64_t activity_at_s = 0;
+    ToolResult refreshed = refreshAgentActivity(activity_at_s);
+    if(!refreshed.success)
+    {
+        expireIfNeeded();
+        return refreshed;
+    }
+    if(!persistAgentActivity(activity_at_s))
+    {
+        return errorResult("RECORD_FAILURE",
+                           "could not update game activity record");
+    }
+
     Json result = wait_ms == 0
         ? state
         : observations_.waitForRevision(
               state.value("revision", 0ULL),
               std::chrono::milliseconds(wait_ms));
 
-    if(arguments.contains("after_message_id"))
+    if(after_message_id)
     {
-        if(!arguments.at("after_message_id").is_number_unsigned())
-        {
-            return errorResult("INVALID_RESPONSE",
-                               "after_message_id must be an integer");
-        }
-        const std::uint64_t after = arguments.at("after_message_id").get<
-            std::uint64_t>();
         const Json all_messages = result.value("messages", Json::array());
         Json filtered = Json::array();
         for(const Json& message : all_messages)
         {
-            if(message.value("id", 0ULL) > after)
+            if(message.value("id", 0ULL) > *after_message_id)
             {
                 filtered.push_back(message);
             }
@@ -498,32 +596,131 @@ ToolResult GameSession::quitGame(const Json& arguments)
     {
         return errorResult("STALE_GAME", error);
     }
-    std::unique_lock action_lock(action_mutex_);
-    stop_requested_ = true;
-    if(process_)
+    std::int64_t activity_at_s = 0;
+    ToolResult refreshed = refreshAgentActivity(activity_at_s);
+    if(!refreshed.success)
     {
-        process_->terminate();
+        expireIfNeeded();
+        return refreshed;
     }
-    Json result = observations_.snapshot();
-    if(result.value("lifecycle", "") == "waiting"
-       || result.value("lifecycle", "") == "starting")
+    if(!persistAgentActivity(activity_at_s))
     {
-        result["lifecycle"] = "aborted";
-        result["pending"] = nullptr;
-        result["operation"] = nullptr;
-        observations_.publish(result);
+        return errorResult("RECORD_FAILURE",
+                           "could not update game activity record");
     }
-    return {true, std::move(result), {}, {}};
+    markTerminal("quit", "observed", "ended");
+    {
+        std::unique_lock action_lock(action_mutex_);
+        if(process_)
+        {
+            process_->terminate(true);
+            process_.reset();
+        }
+    }
+    cleanup();
+    return {true, observations_.snapshot(), {}, {}};
 }
 
 void GameSession::shutdown()
 {
     std::unique_lock action_lock(action_mutex_);
-    stop_requested_ = true;
+    shutdown_requested_ = true;
     if(process_)
     {
-        process_->terminate();
+        process_->terminate(true);
+        process_.reset();
+    }
+}
+
+bool GameSession::expireIfNeeded()
+{
+    std::string reason;
+    const auto now = Clock::now();
+    {
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        if(closing_)
+        {
+            return false;
+        }
+        if(now >= created_at_ + max_game_duration_)
+        {
+            reason = "time_limit";
+        }
+        else if(now >= last_agent_activity_ + idle_timeout_)
+        {
+            reason = "idle_timeout";
+        }
+    }
+    if(reason.empty())
+    {
+        return false;
+    }
+    return markTerminal(reason, "observed", "ended");
+}
+
+bool GameSession::cleanup()
+{
+    {
+        std::unique_lock action_lock(action_mutex_);
+        if(process_)
+        {
+            process_->terminate(true);
+            process_.reset();
+        }
+    }
+    std::optional<std::string> end_reason;
+    std::optional<std::string> end_time_kind;
+    std::int64_t terminal_at_s = 0;
+    {
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        end_reason = end_reason_;
+        end_time_kind = end_time_kind_;
+        terminal_at_s = terminal_at_s_;
+    }
+    if(end_reason && end_time_kind
+       && !finishRecord(*end_reason, *end_time_kind, terminal_at_s))
+    {
+        return false;
+    }
+    std::error_code error;
+    std::filesystem::remove_all(data_root_ / game_id_, error);
+    if(error)
+    {
+        std::fprintf(stderr, "could not remove game directory %s: %s\n",
+                     (data_root_ / game_id_).c_str(), error.message().c_str());
+        return false;
+    }
+    return true;
+}
+
+bool GameSession::terminal() const
+{
+    std::lock_guard lock(lifecycle_mutex_);
+    return closing_;
+}
+
+bool GameSession::workerRunning() const
+{
+    std::lock_guard action_lock(action_mutex_);
+    return process_ && process_->running();
+}
+
+Clock::time_point GameSession::nextDeadline() const
+{
+    std::lock_guard lifecycle_lock(lifecycle_mutex_);
+    if(closing_)
+    {
+        return Clock::now();
     }
+    const auto absolute_deadline = created_at_ + max_game_duration_;
+    const auto idle_deadline = last_agent_activity_ + idle_timeout_;
+    return absolute_deadline < idle_deadline
+        ? absolute_deadline : idle_deadline;
+}
+
+const std::string& GameSession::gameId() const
+{
+    return game_id_;
 }
 
 const std::string& GameSession::viewerUrl() const
@@ -542,10 +739,93 @@ Json GameSession::waitForSnapshot(
     return observations_.waitForRevision(revision, timeout);
 }
 
+bool GameSession::markTerminal(const std::string& reason,
+                               const std::string& end_time_kind,
+                               const std::string& lifecycle)
+{
+    std::string terminal_reason = reason;
+    {
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        if(closing_)
+        {
+            return false;
+        }
+        const auto now = Clock::now();
+        if(now >= created_at_ + max_game_duration_)
+        {
+            terminal_reason = "time_limit";
+        }
+        else if(now >= last_agent_activity_ + idle_timeout_)
+        {
+            terminal_reason = "idle_timeout";
+        }
+        closing_ = true;
+        end_reason_ = terminal_reason;
+        end_time_kind_ = end_time_kind;
+        terminal_at_s_ = wallClockSeconds();
+    }
+
+    Json state = observations_.snapshot();
+    state["lifecycle"] = lifecycle;
+    state["end_reason"] = terminal_reason;
+    state["pending"] = nullptr;
+    state["operation"] = nullptr;
+    observations_.publish(std::move(state));
+    return true;
+}
+
+bool GameSession::finishRecord(const std::string& reason,
+                               const std::string& end_time_kind,
+                               std::int64_t ended_at_s)
+{
+    auto result = records_->finishGame(game_id_, ended_at_s,
+                                       end_time_kind, reason);
+    if(!result)
+    {
+        std::fprintf(stderr, "could not finalize game %s: %s\n",
+                     game_id_.c_str(), mw::errorMsg(result.error()).c_str());
+        return false;
+    }
+    return true;
+}
+
+std::int64_t GameSession::wallClockSeconds()
+{
+    return std::chrono::duration_cast<std::chrono::seconds>(
+        std::chrono::system_clock::now().time_since_epoch()).count();
+}
+
 ToolResult GameSession::sendInput(const Json& arguments, Json response,
                                   const std::string& expected_kind)
 {
+    if(expireIfNeeded())
+    {
+        return errorResult("GAME_CLOSING", "game lifetime has ended");
+    }
     std::unique_lock action_lock(action_mutex_);
+    if(terminal())
+    {
+        return errorResult("GAME_CLOSING", "game cleanup has begun");
+    }
+    std::string expired_reason;
+    {
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        const auto now = Clock::now();
+        if(now >= created_at_ + max_game_duration_)
+        {
+            expired_reason = "time_limit";
+        }
+        else if(now >= last_agent_activity_ + idle_timeout_)
+        {
+            expired_reason = "idle_timeout";
+        }
+    }
+    if(!expired_reason.empty())
+    {
+        action_lock.unlock();
+        markTerminal(expired_reason, "observed", "ended");
+        return errorResult("GAME_CLOSING", "game lifetime has ended");
+    }
     const Json state = observations_.snapshot();
     std::string error;
     if(!validGameId(arguments, state, error))
@@ -584,6 +864,15 @@ ToolResult GameSession::sendInput(const Json& arguments, Json response,
                            "input_id does not identify the pending boundary");
     }
 
+    std::int64_t activity_at_s = 0;
+    ToolResult refreshed = refreshAgentActivity(activity_at_s);
+    if(!refreshed.success)
+    {
+        action_lock.unlock();
+        expireIfNeeded();
+        return refreshed;
+    }
+
     Json operation = {
         {"operation_id", "op_" + std::to_string(++operation_counter_)},
         {"state", "running"},
@@ -600,15 +889,48 @@ ToolResult GameSession::sendInput(const Json& arguments, Json response,
         {"input_id", pending.value("input_id", 0ULL)},
         {"response", std::move(response)},
     };
-    if(!process_->send(input, error))
+    bool sent = false;
+    std::string terminal_race_reason;
     {
-        Json failed = observations_.snapshot();
-        failed["lifecycle"] = "failed";
-        failed["operation"] = nullptr;
-        observations_.publish(std::move(failed));
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        const auto now = Clock::now();
+        if(closing_)
+        {
+            terminal_race_reason = end_reason_.value_or("time_limit");
+        }
+        else if(now >= created_at_ + max_game_duration_)
+        {
+            terminal_race_reason = "time_limit";
+        }
+        else
+        {
+            sent = process_->send(input, error);
+        }
+    }
+    if(!terminal_race_reason.empty())
+    {
+        action_lock.unlock();
+        markTerminal(terminal_race_reason, "observed", "ended");
+        return errorResult("GAME_CLOSING", "game lifetime has ended");
+    }
+    if(!sent)
+    {
+        action_lock.unlock();
+        if(!persistAgentActivity(activity_at_s))
+        {
+            std::fprintf(stderr, "could not update activity for game %s\n",
+                         game_id_.c_str());
+        }
+        markTerminal("failed", "observed", "failed");
         return errorResult("ENGINE_FAILURE", error);
     }
 
+    action_lock.unlock();
+    if(!persistAgentActivity(activity_at_s))
+    {
+        std::fprintf(stderr, "could not update activity for game %s\n",
+                     game_id_.c_str());
+    }
     Json result = observations_.waitForRevision(
         operation_revision, std::chrono::seconds(10));
     return {true, std::move(result), {}, {}};
@@ -621,6 +943,49 @@ ToolResult GameSession::errorResult(std::string code,
             std::move(message)};
 }
 
+ToolResult GameSession::refreshAgentActivity(std::int64_t& activity_at_s)
+{
+    std::string expired_reason;
+    const auto now = Clock::now();
+    {
+        std::lock_guard lifecycle_lock(lifecycle_mutex_);
+        if(closing_)
+        {
+            return errorResult("GAME_CLOSING", "game cleanup has begun");
+        }
+        if(now >= created_at_ + max_game_duration_)
+        {
+            expired_reason = "time_limit";
+        }
+        else if(now >= last_agent_activity_ + idle_timeout_)
+        {
+            expired_reason = "idle_timeout";
+        }
+        else
+        {
+            last_agent_activity_ = now;
+            activity_at_s = wallClockSeconds();
+        }
+    }
+    if(!expired_reason.empty())
+    {
+        return errorResult("GAME_CLOSING", "game lifetime has ended");
+    }
+    return {true, Json::object(), {}, {}};
+}
+
+bool GameSession::persistAgentActivity(std::int64_t activity_at_s)
+{
+    auto updated = records_->updateActivity(game_id_, activity_at_s);
+    if(!updated)
+    {
+        std::fprintf(stderr, "could not update activity for game %s: %s\n",
+                     game_id_.c_str(), mw::errorMsg(updated.error()).c_str());
+        return false;
+    }
+    return true;
+}
+
 void GameSession::handleWorkerMessage(const Json& message)
 {
     const std::string type = message.value("type", "");
@@ -641,30 +1006,84 @@ void GameSession::handleWorkerMessage(const Json& message)
     if(type == "snapshot" && message.contains("snapshot"))
     {
         Json snapshot = message.at("snapshot");
+        std::optional<int> depth;
+        if(snapshot.contains("private_location")
+           && snapshot.at("private_location").is_object()
+           && snapshot.at("private_location").contains("depth")
+           && snapshot.at("private_location").at("depth").is_number_integer())
+        {
+            depth = snapshot.at("private_location").at("depth").get<int>();
+        }
+        snapshot.erase("private_location");
         snapshot["viewer_url"] = viewer_url_;
         snapshot["operation"] = nullptr;
         observations_.publish(std::move(snapshot));
+        if(depth)
+        {
+            bool changed = false;
+            {
+                std::lock_guard lifecycle_lock(lifecycle_mutex_);
+                changed = !last_depth_ || *last_depth_ != *depth;
+            }
+            if(changed)
+            {
+                auto updated = records_->updateLocation(game_id_, *depth);
+                if(!updated)
+                {
+                    std::fprintf(stderr,
+                                 "could not update location for game %s: %s\n",
+                                 game_id_.c_str(),
+                                 mw::errorMsg(updated.error()).c_str());
+                }
+                else
+                {
+                    std::lock_guard lifecycle_lock(lifecycle_mutex_);
+                    last_depth_ = *depth;
+                }
+            }
+        }
+        return;
+    }
+    if(type == "terminal_result")
+    {
+        const std::string reason = message.value("end_reason", "");
+        if(reason == "ascended" || reason == "escaped" || reason == "died"
+           || reason == "quit" || reason == "failed")
+        {
+            std::lock_guard lifecycle_lock(lifecycle_mutex_);
+            if(!closing_)
+            {
+                terminal_result_ = reason;
+            }
+        }
         return;
     }
     if(type == "exiting")
     {
-        Json snapshot = observations_.snapshot();
-        if(stop_requested_)
+        if(shutdown_requested_)
         {
-            snapshot["lifecycle"] = "aborted";
+            return;
+        }
+        std::string reason = "failed";
+        {
+            std::lock_guard lifecycle_lock(lifecycle_mutex_);
+            if(closing_)
+            {
+                return;
+            }
+            if(terminal_result_)
+            {
+                reason = *terminal_result_;
+            }
         }
-        else if(message.value("exit_code", -1) == 0
-                && message.value("signal", 0) == 0)
+        if(reason == "failed")
         {
-            snapshot["lifecycle"] = "ended";
+            markTerminal("failed", "observed", "failed");
         }
         else
         {
-            snapshot["lifecycle"] = "failed";
+            markTerminal(reason, "observed", "ended");
         }
-        snapshot["pending"] = nullptr;
-        snapshot["operation"] = nullptr;
-        observations_.publish(std::move(snapshot));
     }
 }
 
@@ -769,13 +1188,4 @@ bool GameSession::parseKey(const Json& key, int& value, std::string& error)
     return value != 0;
 }
 
-std::string GameSession::makeGameId()
-{
-    static std::atomic<std::uint64_t> counter = 0;
-    const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
-        Clock::now().time_since_epoch()).count();
-    return "g_" + std::to_string(now) + "_"
-        + std::to_string(++counter);
-}
-
 } // namespace nethack_mcp
diff --git a/src/identity.cpp b/src/identity.cpp
new file mode 100644
index 0000000..1c4b445
--- /dev/null
+++ b/src/identity.cpp
@@ -0,0 +1,172 @@
+#include "identity.hpp"
+
+#include <array>
+#include <cerrno>
+#include <chrono>
+#include <cstdint>
+#include <sys/types.h>
+#include <string>
+#include <string_view>
+#include <sys/random.h>
+
+#include <openssl/crypto.h>
+#include <openssl/evp.h>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+constexpr char BASE64URL_ALPHABET[] =
+    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+std::string encodeBase64Url(const unsigned char* bytes, std::size_t size)
+{
+    std::string encoded;
+    encoded.reserve((size * 4 + 2) / 3);
+    for(std::size_t index = 0; index < size; index += 3)
+    {
+        const std::uint32_t first = bytes[index];
+        const std::uint32_t second = index + 1 < size ? bytes[index + 1] : 0;
+        const std::uint32_t third = index + 2 < size ? bytes[index + 2] : 0;
+        const std::uint32_t block = (first << 16) | (second << 8) | third;
+        encoded.push_back(BASE64URL_ALPHABET[(block >> 18) & 0x3f]);
+        encoded.push_back(BASE64URL_ALPHABET[(block >> 12) & 0x3f]);
+        if(index + 1 < size)
+        {
+            encoded.push_back(BASE64URL_ALPHABET[(block >> 6) & 0x3f]);
+        }
+        if(index + 2 < size)
+        {
+            encoded.push_back(BASE64URL_ALPHABET[block & 0x3f]);
+        }
+    }
+    return encoded;
+}
+
+bool lowerHex(char value)
+{
+    return (value >= '0' && value <= '9')
+        || (value >= 'a' && value <= 'f');
+}
+
+} // namespace
+
+std::string makeGameId()
+{
+    std::array<unsigned char, 16> bytes{};
+    if(!secureRandom(bytes.data(), bytes.size()))
+    {
+        return {};
+    }
+
+    const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
+        std::chrono::system_clock::now().time_since_epoch()).count();
+    const std::uint64_t timestamp = static_cast<std::uint64_t>(now);
+    for(int index = 5; index >= 0; --index)
+    {
+        bytes[static_cast<std::size_t>(index)] =
+            static_cast<unsigned char>(timestamp >> (8 * (5 - index)));
+    }
+    bytes[6] = static_cast<unsigned char>((bytes[6] & 0x0f) | 0x70);
+    bytes[8] = static_cast<unsigned char>((bytes[8] & 0x3f) | 0x80);
+
+    constexpr char HEX[] = "0123456789abcdef";
+    std::string game_id;
+    game_id.reserve(36);
+    for(std::size_t index = 0; index < bytes.size(); ++index)
+    {
+        if(index == 4 || index == 6 || index == 8 || index == 10)
+        {
+            game_id.push_back('-');
+        }
+        game_id.push_back(HEX[bytes[index] >> 4]);
+        game_id.push_back(HEX[bytes[index] & 0x0f]);
+    }
+    return game_id;
+}
+
+bool validGameId(std::string_view game_id)
+{
+    if(game_id.size() != 36 || game_id[8] != '-' || game_id[13] != '-'
+       || game_id[18] != '-' || game_id[23] != '-')
+    {
+        return false;
+    }
+    for(std::size_t index = 0; index < game_id.size(); ++index)
+    {
+        if(index == 8 || index == 13 || index == 18 || index == 23)
+        {
+            continue;
+        }
+        if(!lowerHex(game_id[index]))
+        {
+            return false;
+        }
+    }
+    return game_id[14] == '7'
+        && (game_id[19] == '8' || game_id[19] == '9'
+            || game_id[19] == 'a' || game_id[19] == 'b');
+}
+
+std::string makeControlToken()
+{
+    std::array<unsigned char, 32> bytes{};
+    if(!secureRandom(bytes.data(), bytes.size()))
+    {
+        return {};
+    }
+    return encodeBase64Url(bytes.data(), bytes.size());
+}
+
+std::array<unsigned char, 32> hashControlToken(
+    const std::array<unsigned char, 32>& salt, std::string_view token)
+{
+    std::array<unsigned char, 32> digest{};
+    EVP_MD_CTX* context = EVP_MD_CTX_new();
+    if(context == nullptr)
+    {
+        return digest;
+    }
+    unsigned int digest_size = 0;
+    const bool success = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1
+        && EVP_DigestUpdate(context, salt.data(), salt.size()) == 1
+        && EVP_DigestUpdate(context, token.data(), token.size()) == 1
+        && EVP_DigestFinal_ex(context, digest.data(), &digest_size) == 1
+        && digest_size == digest.size();
+    EVP_MD_CTX_free(context);
+    if(!success)
+    {
+        digest.fill(0);
+    }
+    return digest;
+}
+
+bool secureDigestEqual(const std::array<unsigned char, 32>& left,
+                       const std::array<unsigned char, 32>& right)
+{
+    return CRYPTO_memcmp(left.data(), right.data(), left.size()) == 0;
+}
+
+bool secureRandom(void* destination, std::size_t size)
+{
+    auto* output = static_cast<unsigned char*>(destination);
+    std::size_t offset = 0;
+    while(offset < size)
+    {
+        const ssize_t count = ::getrandom(output + offset, size - offset, 0);
+        if(count < 0)
+        {
+            if(errno == EINTR)
+            {
+                continue;
+            }
+            return false;
+        }
+        offset += static_cast<std::size_t>(count);
+    }
+    return true;
+}
+
+} // namespace nethack_mcp
diff --git a/src/main.cpp b/src/main.cpp
index 8352951..7ca2535 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,16 +1,21 @@
 #include "engine_worker.hpp"
 #include "game_http_server.hpp"
-#include "game_session.hpp"
+#include "game_manager.hpp"
 #include "mcp_server.hpp"
+#include "server_config.hpp"
 
+#include <algorithm>
 #include <cstdio>
 #include <cstdlib>
 #include <chrono>
 #include <csignal>
 #include <exception>
 #include <filesystem>
+#include <limits>
 #include <string>
+#include <sys/resource.h>
 #include <thread>
+#include <utility>
 
 namespace
 {
@@ -22,51 +27,390 @@ void requestStop(int)
     stop_requested = 1;
 }
 
-struct Options
+enum class ParseResult
 {
-    std::filesystem::path data_root;
-    int port = 8765;
+    SUCCESS,
+    HELP,
+    ERROR,
 };
 
-bool parseOptions(int argc, char* argv[], Options& options)
+bool parsePositive(const std::string& text, std::size_t& value)
 {
-    options.data_root = std::filesystem::temp_directory_path()
+    if(text.empty() || text.front() == '-')
+    {
+        return false;
+    }
+    try
+    {
+        std::size_t consumed = 0;
+        const unsigned long long parsed = std::stoull(text, &consumed);
+        if(consumed != text.size() || parsed == 0
+           || parsed > static_cast<unsigned long long>(
+               std::numeric_limits<std::size_t>::max())
+           || parsed > static_cast<unsigned long long>(
+               std::numeric_limits<std::int64_t>::max()))
+        {
+            return false;
+        }
+        value = static_cast<std::size_t>(parsed);
+        return true;
+    }
+    catch(const std::exception&)
+    {
+        return false;
+    }
+}
+
+bool validResourceBounds(const nethack_mcp::ServerConfig& config)
+{
+    constexpr std::size_t MAX_ACTIVE_GAMES = 1024;
+    constexpr std::size_t MAX_HTTP_WORKERS = 1024;
+    constexpr std::size_t MAX_HTTP_CONNECTIONS = 1'000'000;
+    constexpr std::size_t MAX_RATE_LIMIT_CLIENTS = 1'000'000;
+    constexpr std::size_t MAX_MCP_BODY_BYTES = 64U * 1024U * 1024U;
+    constexpr std::size_t MAX_WORKER_OUTPUT_BYTES = 1024U * 1024U * 1024U;
+    constexpr std::int64_t MAX_DURATION_SECONDS = 315'360'000;
+    const auto valid_duration = [](std::chrono::seconds duration) {
+        return duration.count() > 0
+            && duration.count() <= MAX_DURATION_SECONDS;
+    };
+
+    return config.max_active_games <= MAX_ACTIVE_GAMES
+        && config.max_concurrent_requests <= MAX_HTTP_WORKERS
+        && config.max_open_connections <= MAX_HTTP_CONNECTIONS
+        && config.new_games_per_client <= 1'000'000
+        && config.control_failures_per_client <= 10'000
+        && config.max_rate_limit_clients <= MAX_RATE_LIMIT_CLIENTS
+        && config.max_viewer_long_polls <= MAX_HTTP_WORKERS
+        && config.max_mcp_body_bytes <= MAX_MCP_BODY_BYTES
+        && config.max_worker_output_bytes <= MAX_WORKER_OUTPUT_BYTES
+        && valid_duration(config.new_game_rate_window)
+        && valid_duration(config.control_failure_window)
+        && valid_duration(config.idle_timeout)
+        && valid_duration(config.max_game_duration)
+        && valid_duration(config.lifecycle_sweep_interval)
+        && valid_duration(config.state_long_poll_timeout);
+}
+
+bool parsePort(const std::string& text, int& port)
+{
+    std::size_t parsed = 0;
+    if(!parsePositive(text, parsed) || parsed > 65535)
+    {
+        return false;
+    }
+    port = static_cast<int>(parsed);
+    return true;
+}
+
+void printUsage()
+{
+    std::fputs(
+        "Usage: nethack_mcp [options]\n"
+        "  --port PORT                         Loopback HTTP port (8765)\n"
+        "  --data-root PATH                    Temporary game files\n"
+        "  --database PATH                     Persistent SQLite records\n"
+        "  --public-base-url URL               Canonical public URL ending in /\n"
+        "  --allowed-host HOST                 Allowed Host header (repeatable)\n"
+        "  --allowed-origin ORIGIN             Allowed Origin header (repeatable)\n"
+        "  --trusted-proxy ADDRESS             Trusted reverse proxy (repeatable)\n"
+        "  --max-active-games N                Active game capacity\n"
+        "  --new-games-per-client N            Creation quota per rate window\n"
+        "  --new-game-rate-window-seconds N    Creation quota window\n"
+        "  --control-failures-per-client N     Bad-token limit (10)\n"
+        "  --control-failure-window-seconds N Bad-token window (60)\n"
+        "  --max-rate-limit-clients N          Tracked client cap (4096)\n"
+        "  --max-concurrent-requests N         HTTP request worker limit\n"
+        "  --max-open-connections N            Total HTTP connection limit\n"
+        "  --max-viewer-long-polls N           Simultaneous spectator waits\n"
+        "  --idle-timeout-seconds N            Idle game lifetime (600)\n"
+        "  --max-game-duration-seconds N       Absolute game lifetime (86400)\n"
+        "  --lifecycle-sweep-seconds N         Expiry sweep interval (15)\n"
+        "  --state-long-poll-seconds N         Viewer state wait (15)\n"
+        "  --max-mcp-body-bytes N              MCP request size (1048576)\n"
+        "  --max-worker-output-bytes N         Worker log byte limit\n"
+        "  --help                              Show this help\n",
+        stdout);
+}
+
+ParseResult parseOptions(int argc, char* argv[],
+                         nethack_mcp::ServerConfig& config)
+{
+    config.data_root = std::filesystem::temp_directory_path()
         / "nethack-mcp";
+    const char* home = std::getenv("HOME");
+    const std::filesystem::path home_path = home != nullptr && home[0] != '\0'
+        ? std::filesystem::path(home) : std::filesystem::current_path();
+    config.database_path = home_path / ".local" / "share" / "nethack-mcp"
+        / "games.sqlite3";
+
+    bool base_url_explicit = false;
+    bool hosts_explicit = false;
+    bool origins_explicit = false;
+    bool proxies_explicit = false;
+    bool active_games_explicit = false;
+    bool new_games_explicit = false;
+    bool rate_window_explicit = false;
+    bool rate_limit_clients_explicit = false;
+    bool concurrent_requests_explicit = false;
+    bool open_connections_explicit = false;
+    bool long_polls_explicit = false;
+    bool worker_output_explicit = false;
     for(int index = 1; index < argc; ++index)
     {
         const std::string argument = argv[index];
-        if(argument == "--data-root" && index + 1 < argc)
+        if(argument == "--help")
         {
-            options.data_root = argv[++index];
+            printUsage();
+            return ParseResult::HELP;
         }
-        else if(argument == "--port" && index + 1 < argc)
+        if(index + 1 >= argc)
         {
-            try
+            return ParseResult::ERROR;
+        }
+        const std::string value = argv[++index];
+        std::size_t number = 0;
+        if(argument == "--port")
+        {
+            if(!parsePort(value, config.port)) return ParseResult::ERROR;
+        }
+        else if(argument == "--data-root")
+        {
+            config.data_root = value;
+        }
+        else if(argument == "--database")
+        {
+            config.database_path = value;
+        }
+        else if(argument == "--public-base-url")
+        {
+            config.public_base_url = value;
+            base_url_explicit = true;
+        }
+        else if(argument == "--allowed-host")
+        {
+            if(!hosts_explicit)
             {
-                options.port = std::stoi(argv[++index]);
+                config.allowed_hosts.clear();
+                hosts_explicit = true;
             }
-            catch(const std::exception&)
+            config.allowed_hosts.push_back(value);
+        }
+        else if(argument == "--allowed-origin")
+        {
+            if(!origins_explicit)
             {
-                return false;
+                config.allowed_origins.clear();
+                origins_explicit = true;
             }
-            if(options.port < 1 || options.port > 65535)
+            config.allowed_origins.push_back(value);
+        }
+        else if(argument == "--trusted-proxy")
+        {
+            if(!proxies_explicit)
             {
-                return false;
+                config.trusted_proxy_addresses.clear();
+                proxies_explicit = true;
             }
+            config.trusted_proxy_addresses.push_back(value);
         }
-        else if(argument == "--help")
+        else if(argument == "--max-active-games")
         {
-            std::fputs(
-                "Usage: nethack_mcp [--data-root PATH] [--port PORT]\n",
-                stdout);
-            return false;
+            if(!parsePositive(value, config.max_active_games))
+                return ParseResult::ERROR;
+            active_games_explicit = true;
+        }
+        else if(argument == "--new-games-per-client")
+        {
+            if(!parsePositive(value, config.new_games_per_client))
+                return ParseResult::ERROR;
+            new_games_explicit = true;
+        }
+        else if(argument == "--new-game-rate-window-seconds")
+        {
+            if(!parsePositive(value, number)) return ParseResult::ERROR;
+            config.new_game_rate_window = std::chrono::seconds(number);
+            rate_window_explicit = true;
+        }
+        else if(argument == "--control-failures-per-client")
+        {
+            if(!parsePositive(value, config.control_failures_per_client))
+                return ParseResult::ERROR;
+        }
+        else if(argument == "--control-failure-window-seconds")
+        {
+            if(!parsePositive(value, number)) return ParseResult::ERROR;
+            config.control_failure_window = std::chrono::seconds(number);
+        }
+        else if(argument == "--max-rate-limit-clients")
+        {
+            if(!parsePositive(value, config.max_rate_limit_clients))
+                return ParseResult::ERROR;
+            rate_limit_clients_explicit = true;
+        }
+        else if(argument == "--max-concurrent-requests")
+        {
+            if(!parsePositive(value, config.max_concurrent_requests))
+                return ParseResult::ERROR;
+            concurrent_requests_explicit = true;
+        }
+        else if(argument == "--max-open-connections")
+        {
+            if(!parsePositive(value, config.max_open_connections))
+                return ParseResult::ERROR;
+            open_connections_explicit = true;
+        }
+        else if(argument == "--max-viewer-long-polls")
+        {
+            if(!parsePositive(value, config.max_viewer_long_polls))
+                return ParseResult::ERROR;
+            long_polls_explicit = true;
+        }
+        else if(argument == "--idle-timeout-seconds")
+        {
+            if(!parsePositive(value, number)) return ParseResult::ERROR;
+            config.idle_timeout = std::chrono::seconds(number);
+        }
+        else if(argument == "--max-game-duration-seconds")
+        {
+            if(!parsePositive(value, number)) return ParseResult::ERROR;
+            config.max_game_duration = std::chrono::seconds(number);
+        }
+        else if(argument == "--lifecycle-sweep-seconds")
+        {
+            if(!parsePositive(value, number)) return ParseResult::ERROR;
+            config.lifecycle_sweep_interval = std::chrono::seconds(number);
+        }
+        else if(argument == "--state-long-poll-seconds")
+        {
+            if(!parsePositive(value, number)) return ParseResult::ERROR;
+            config.state_long_poll_timeout = std::chrono::seconds(number);
+        }
+        else if(argument == "--max-mcp-body-bytes")
+        {
+            if(!parsePositive(value, config.max_mcp_body_bytes))
+                return ParseResult::ERROR;
+        }
+        else if(argument == "--max-worker-output-bytes")
+        {
+            if(!parsePositive(value, config.max_worker_output_bytes))
+                return ParseResult::ERROR;
+            worker_output_explicit = true;
         }
         else
         {
-            return false;
+            return ParseResult::ERROR;
         }
     }
-    return true;
+
+    if(!base_url_explicit)
+    {
+        config.public_base_url = "http://127.0.0.1:"
+            + std::to_string(config.port) + "/";
+    }
+    if(!hosts_explicit)
+    {
+        config.allowed_hosts = {
+            "127.0.0.1:" + std::to_string(config.port),
+            "localhost:" + std::to_string(config.port),
+        };
+    }
+    if(!origins_explicit)
+    {
+        config.allowed_origins = {
+            "http://127.0.0.1:" + std::to_string(config.port),
+            "http://localhost:" + std::to_string(config.port),
+        };
+    }
+    if(config.public_base_url.empty()
+       || (config.public_base_url.rfind("https://", 0) != 0
+           && config.public_base_url.rfind("http://", 0) != 0)
+       || config.public_base_url.back() != '/')
+    {
+        return ParseResult::ERROR;
+    }
+    const std::size_t scheme_end = config.public_base_url.find("://");
+    const std::size_t path_start = config.public_base_url.find('/', scheme_end + 3);
+    if(path_start != config.public_base_url.size() - 1)
+    {
+        return ParseResult::ERROR;
+    }
+    const std::string authority = config.public_base_url.substr(
+        scheme_end + 3, path_start - (scheme_end + 3));
+    if(authority.empty()
+       || std::find(config.allowed_hosts.begin(), config.allowed_hosts.end(),
+                    authority) == config.allowed_hosts.end())
+    {
+        return ParseResult::ERROR;
+    }
+    const std::string origin = config.public_base_url.substr(0, path_start);
+    if(std::find(config.allowed_origins.begin(), config.allowed_origins.end(),
+                 origin) == config.allowed_origins.end())
+    {
+        return ParseResult::ERROR;
+    }
+    const std::string host_name = authority.substr(0, authority.find(':'));
+    const bool loopback = host_name == "127.0.0.1"
+        || host_name == "localhost";
+    if(!loopback && config.public_base_url.rfind("https://", 0) != 0)
+    {
+        return ParseResult::ERROR;
+    }
+    if(!loopback && !proxies_explicit)
+    {
+        return ParseResult::ERROR;
+    }
+    if(!loopback
+       && (!active_games_explicit || !new_games_explicit
+           || !rate_window_explicit || !rate_limit_clients_explicit
+           || !concurrent_requests_explicit
+           || !open_connections_explicit || !long_polls_explicit
+           || !worker_output_explicit))
+    {
+        return ParseResult::ERROR;
+    }
+    if(config.max_open_connections <= config.max_concurrent_requests
+       || config.max_viewer_long_polls > config.max_concurrent_requests
+       || !validResourceBounds(config))
+    {
+        return ParseResult::ERROR;
+    }
+    return ParseResult::SUCCESS;
+}
+
+bool configureDescriptorLimit(const nethack_mcp::ServerConfig& config)
+{
+    struct rlimit limits{};
+    if(::getrlimit(RLIMIT_NOFILE, &limits) != 0)
+    {
+        return false;
+    }
+    constexpr rlim_t RESERVED_DESCRIPTORS = 64;
+    const rlim_t maximum = std::numeric_limits<rlim_t>::max();
+    const rlim_t active_games = static_cast<rlim_t>(config.max_active_games);
+    const rlim_t open_connections =
+        static_cast<rlim_t>(config.max_open_connections);
+    if(static_cast<std::size_t>(active_games) != config.max_active_games
+       || static_cast<std::size_t>(open_connections)
+           != config.max_open_connections
+       || active_games > (maximum - RESERVED_DESCRIPTORS) / 2)
+    {
+        return false;
+    }
+    const rlim_t worker_descriptors = 2 * active_games;
+    if(open_connections > maximum - worker_descriptors
+                              - RESERVED_DESCRIPTORS)
+    {
+        return false;
+    }
+    const rlim_t desired = open_connections + worker_descriptors
+        + RESERVED_DESCRIPTORS;
+    if(desired > limits.rlim_max)
+    {
+        return false;
+    }
+    limits.rlim_cur = desired;
+    return ::setrlimit(RLIMIT_NOFILE, &limits) == 0;
 }
 
 } // namespace
@@ -78,45 +422,56 @@ int main(int argc, char* argv[])
         return nethack_mcp::runEngineWorker(argc, argv);
     }
 
-    Options options;
-    if(!parseOptions(argc, argv, options))
+    nethack_mcp::ServerConfig config;
+    const ParseResult parsed = parseOptions(argc, argv, config);
+    if(parsed == ParseResult::HELP)
+    {
+        return 0;
+    }
+    if(parsed == ParseResult::ERROR)
+    {
+        std::fputs("invalid or incomplete command line options; use --help\n",
+                   stderr);
+        return 2;
+    }
+    if(!configureDescriptorLimit(config))
     {
-        return argc > 1 && std::string(argv[1]) == "--help" ? 0 : 2;
+        std::fputs("could not apply the configured open-connection limit\n",
+                   stderr);
+        return 1;
     }
 
-    try
+    auto manager_result = nethack_mcp::GameManager::create(
+        std::move(config), NETHACK_RUNTIME_DIR);
+    if(!manager_result)
     {
-        nethack_mcp::GameSession session(
-            options.data_root,
-            NETHACK_RUNTIME_DIR,
-            "http://127.0.0.1:" + std::to_string(options.port) + "/");
-        nethack_mcp::McpServer mcp(session);
-        nethack_mcp::GameHttpServer http_server(session, mcp,
-                                               options.port);
-        std::string error;
-        if(!http_server.startServer(error))
-        {
-            std::fprintf(stderr, "%s\n", error.c_str());
-            return 1;
-        }
-        std::fprintf(stderr, "MCP: http://127.0.0.1:%d/mcp\n",
-                     options.port);
-        std::fprintf(stderr, "Spectator: %s\n",
-                     session.viewerUrl().c_str());
-        std::signal(SIGINT, requestStop);
-        std::signal(SIGTERM, requestStop);
-        while(!stop_requested && http_server.running())
-        {
-            std::this_thread::sleep_for(std::chrono::milliseconds(200));
-        }
-        const bool interrupted = stop_requested != 0;
-        session.shutdown();
-        http_server.stopServer();
-        return interrupted ? 0 : 1;
-    }
-    catch(const std::exception& exception)
-    {
-        std::fprintf(stderr, "nethack_mcp: %s\n", exception.what());
+        std::fprintf(stderr, "could not initialize game manager: %s\n",
+                     mw::errorMsg(manager_result.error()).c_str());
         return 1;
     }
+    std::unique_ptr<nethack_mcp::GameManager> manager =
+        std::move(*manager_result);
+    nethack_mcp::McpServer mcp(*manager);
+    nethack_mcp::GameHttpServer http_server(*manager, mcp);
+    std::string error;
+    if(!http_server.startServer(error))
+    {
+        std::fprintf(stderr, "%s\n", error.c_str());
+        manager->shutdown();
+        return 1;
+    }
+    std::fprintf(stderr, "MCP: %smcp\n",
+                 manager->publicBaseUrl().c_str());
+    std::fprintf(stderr, "Spectators: %s\n",
+                 manager->publicBaseUrl().c_str());
+    std::signal(SIGINT, requestStop);
+    std::signal(SIGTERM, requestStop);
+    while(!stop_requested && http_server.running())
+    {
+        std::this_thread::sleep_for(std::chrono::milliseconds(200));
+    }
+    const bool interrupted = stop_requested != 0;
+    http_server.stopServer();
+    manager->shutdown();
+    return interrupted ? 0 : 1;
 }
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
index a02a9a7..ffc53c7 100644
--- a/src/mcp_server.cpp
+++ b/src/mcp_server.cpp
@@ -1,6 +1,7 @@
 #include "mcp_server.hpp"
 
 #include <string>
+#include <string_view>
 #include <utility>
 
 namespace nethack_mcp
@@ -9,13 +10,17 @@ namespace nethack_mcp
 namespace
 {
 
-bool supportedProtocolVersion(const std::string& version)
+bool supportedLegacyProtocolVersion(const std::string& version)
 {
     return version == "2025-11-25"
         || version == "2025-06-18"
         || version == "2025-03-26";
 }
 
+constexpr std::string_view MODERN_PROTOCOL_VERSION = "2026-07-28";
+constexpr std::string_view SERVER_INFO_META_KEY =
+    "io.modelcontextprotocol/serverInfo";
+
 Json objectSchema(Json properties, Json required = Json::array())
 {
     return {
@@ -35,18 +40,43 @@ Json gameIdProperty()
 {
     return {
         {"type", "string"},
-        {"minLength", 1},
-        {"maxLength", 128},
+        {"minLength", 36},
+        {"maxLength", 36},
+        {"pattern",
+         "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-"
+         "[0-9a-f]{12}$"},
+    };
+}
+
+Json controlTokenProperty()
+{
+    return {
+        {"type", "string"},
+        {"minLength", 43},
+        {"maxLength", 43},
+        {"pattern", "^[A-Za-z0-9_-]{43}$"},
     };
 }
 
 } // namespace
 
-McpServer::McpServer(GameSession& session)
-        : session_(session)
+McpServer::McpServer(GameManager& manager)
+        : manager_(manager)
 {}
 
-Json McpServer::handleMessage(const Json& request, bool& should_respond)
+Json McpServer::handleMessage(const Json& request, bool& should_respond,
+                              const std::string& client_id,
+                              bool modern_protocol)
+{
+    Json reply = handleMessageInternal(
+        request, should_respond, client_id, modern_protocol);
+    return addModernMetadata(std::move(reply), request, modern_protocol);
+}
+
+Json McpServer::handleMessageInternal(const Json& request,
+                                      bool& should_respond,
+                                      const std::string& client_id,
+                                      bool modern_protocol)
 {
     should_respond = true;
     if(!request.is_object()
@@ -76,6 +106,11 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
     }
     if(method == "initialize")
     {
+        if(modern_protocol)
+        {
+            return jsonRpcError(id, -32601,
+                                "initialize is not available in this protocol");
+        }
         const Json params = request.value("params", Json::object());
         if(!params.is_object())
         {
@@ -89,10 +124,10 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
                                 "protocolVersion must be a string");
         }
         const std::string version = params.value("protocolVersion", "");
-        if(!version.empty() && !supportedProtocolVersion(version))
+        if(!version.empty() && !supportedLegacyProtocolVersion(version))
         {
             return jsonRpcError(id, -32602,
-                                "unsupported MCP protocol version: " + version);
+                                "unsupported MCP protocol version");
         }
         const std::string negotiated_version = version.empty()
             ? "2025-11-25" : version;
@@ -111,8 +146,28 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
     }
     if(method == "ping")
     {
+        if(modern_protocol)
+        {
+            return jsonRpcError(id, -32601,
+                                "ping is not available in this protocol");
+        }
         return {{"jsonrpc", "2.0"}, {"id", id}, {"result", {}}};
     }
+    if(method == "server/discover")
+    {
+        if(!modern_protocol)
+        {
+            return jsonRpcError(id, -32601, "method not found: server/discover");
+        }
+        return {
+            {"jsonrpc", "2.0"},
+            {"id", id},
+            {"result", {
+                {"supportedVersions", {std::string(MODERN_PROTOCOL_VERSION)}},
+                {"capabilities", {{"tools", Json::object()}}},
+            }},
+        };
+    }
     if(method == "resources/list")
     {
         return {
@@ -153,15 +208,14 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
 
         const std::string name = params.at("name").get<std::string>();
         ToolResult result;
-        if(name == "new_game") result = session_.newGame(arguments);
-        else if(name == "observe") result = session_.observe(arguments);
-        else if(name == "press") result = session_.press(arguments);
-        else if(name == "select_menu") result = session_.selectMenu(arguments);
-        else if(name == "respond") result = session_.respond(arguments);
-        else if(name == "quit_game") result = session_.quitGame(arguments);
+        if(name == "new_game") result = manager_.createGame(arguments, client_id);
+        else if(name == "observe" || name == "press"
+                || name == "select_menu" || name == "respond"
+                || name == "quit_game")
+            result = manager_.dispatch(name, arguments, client_id);
         else
         {
-            return jsonRpcError(id, -32602, "unknown tool: " + name);
+            return jsonRpcError(id, -32602, "unknown tool");
         }
         return {
             {"jsonrpc", "2.0"},
@@ -169,7 +223,38 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
             {"result", toolResult(result)},
         };
     }
-    return jsonRpcError(id, -32601, "method not found: " + method);
+    return jsonRpcError(id, -32601, "method not found");
+}
+
+Json McpServer::addModernMetadata(Json response, const Json& request,
+                                  bool modern_protocol) const
+{
+    if(!modern_protocol || !response.is_object())
+    {
+        return response;
+    }
+    const std::string method = request.is_object()
+            && request.contains("method") && request.at("method").is_string()
+        ? request.at("method").get<std::string>() : std::string();
+
+    const Json server_info = {
+        {"name", "nethack-mcp"},
+        {"version", "0.1.0"},
+    };
+    if(response.contains("result") && response.at("result").is_object())
+    {
+        Json& result = response["result"];
+        result["_meta"][std::string(SERVER_INFO_META_KEY)] = server_info;
+        result["resultType"] = "complete";
+        if(method == "server/discover" || method == "tools/list"
+           || method == "resources/list"
+           || method == "resources/templates/list")
+        {
+            result["ttlMs"] = 300000;
+            result["cacheScope"] = "public";
+        }
+    }
+    return response;
 }
 
 Json McpServer::tools() const
@@ -184,46 +269,56 @@ Json McpServer::tools() const
     return Json::array({
         {
             {"name", "new_game"},
-            {"description", "Start one fresh NetHack game."},
+            {"description", "Start one fresh NetHack game and return its control token."},
             {"inputSchema", objectSchema({
-                {"name", {{"type", "string"}, {"maxLength", 30}}},
+                {"name", {
+                    {"type", "string"}, {"minLength", 1},
+                    {"maxLength", 30}, {"pattern", "^[ -~]+$"},
+                }},
+                {"model_slug", {
+                    {"type", "string"}, {"minLength", 1},
+                    {"maxLength", 128}, {"pattern", "^[ -~]+$"},
+                }},
                 {"role", stringProperty()},
                 {"race", stringProperty()},
                 {"gender", stringProperty()},
                 {"alignment", stringProperty()},
-            })},
+            }, {"model_slug"})},
         },
         {
             {"name", "observe"},
             {"description", "Read the latest complete game observation."},
             {"inputSchema", objectSchema({
                 {"game_id", game_id},
+                {"control_token", controlTokenProperty()},
                 {"detail", {{"type", "string"},
                              {"enum", {"compact", "full"}}}},
                 {"after_message_id", {{"type", "integer"}, {"minimum", 0}}},
                 {"wait_ms", {{"type", "integer"},
                               {"minimum", 0}, {"maximum", 10000}}},
-            })},
+            }, {"game_id", "control_token"})},
         },
         {
             {"name", "press"},
             {"description", "Send one key to a pending key boundary."},
             {"inputSchema", objectSchema({
                 {"game_id", game_id},
+                {"control_token", controlTokenProperty()},
                 {"input_id", {{"type", "integer"}, {"minimum", 1}}},
                 {"key", stringProperty()},
-            }, {"game_id", "input_id", "key"})},
+            }, {"game_id", "control_token", "input_id", "key"})},
         },
         {
             {"name", "select_menu"},
             {"description", "Submit complete selections for a menu."},
             {"inputSchema", objectSchema({
                 {"game_id", game_id},
+                {"control_token", controlTokenProperty()},
                 {"input_id", {{"type", "integer"}, {"minimum", 1}}},
                 {"selections", {{"type", "array"},
                                  {"items", selection}}},
                 {"cancel", {{"type", "boolean"}}},
-            }, {"game_id", "input_id", "selections"})},
+            }, {"game_id", "control_token", "input_id", "selections"})},
         },
         {
             {"name", "respond"},
@@ -232,6 +327,7 @@ Json McpServer::tools() const
                 {"type", "object"},
                 {"properties", {
                     {"game_id", game_id},
+                    {"control_token", controlTokenProperty()},
                     {"input_id", {{"type", "integer"}, {"minimum", 1}}},
                     {"text", stringProperty()},
                     {"choice", stringProperty()},
@@ -239,7 +335,7 @@ Json McpServer::tools() const
                     {"acknowledge", {{"type", "boolean"}}},
                     {"cancel", {{"type", "boolean"}}},
                 }},
-                {"required", {"game_id", "input_id"}},
+                {"required", {"game_id", "control_token", "input_id"}},
                 {"additionalProperties", false},
                 {"oneOf", {
                     {{"required", {"text"}}},
@@ -255,7 +351,8 @@ Json McpServer::tools() const
             {"description", "Stop the active worker administratively."},
             {"inputSchema", objectSchema({
                 {"game_id", game_id},
-            }, {"game_id"})},
+                {"control_token", controlTokenProperty()},
+            }, {"game_id", "control_token"})},
         },
     });
 }
diff --git a/src/window_adapter.cpp b/src/window_adapter.cpp
index b1c93ce..5e915c2 100644
--- a/src/window_adapter.cpp
+++ b/src/window_adapter.cpp
@@ -12,13 +12,8 @@
 
 extern "C"
 {
-#include "config.h"
-#include "integer.h"
-#include "tradstdc.h"
-#include "global.h"
-#include "wintype.h"
+#include "hack.h"
 #include "func_tab.h"
-#include "botl.h"
 }
 
 namespace nethack_mcp
@@ -631,10 +626,47 @@ Json WindowAdapter::makeSnapshot() const
         {"messages_truncated", false},
         {"inventory", inventory_},
         {"pending", nullptr},
+        {"private_location", {{"depth", static_cast<int>(depth(&u.uz))}}},
     };
     return snapshot;
 }
 
+void WindowAdapter::reportTerminalResult(int how)
+{
+    if(active_adapter_ == nullptr)
+    {
+        return;
+    }
+    std::string reason = "failed";
+    if(how == ASCENDED)
+    {
+        reason = "ascended";
+    }
+    else if(how == ESCAPED)
+    {
+        reason = "escaped";
+    }
+    else if(how == QUIT)
+    {
+        reason = "quit";
+    }
+    else if(how >= DIED && how < PANICKED)
+    {
+        reason = "died";
+    }
+    std::string error;
+    if(!active_adapter_->channel_.send({
+           {"type", "terminal_result"},
+           {"game_id", active_adapter_->game_id_},
+           {"native_how", how},
+           {"end_reason", reason},
+       }, error))
+    {
+        std::fprintf(stderr, "could not send terminal result: %s\n",
+                     error.c_str());
+    }
+}
+
 Json WindowAdapter::makePending(std::string kind, std::string source) const
 {
     return {
@@ -766,3 +798,8 @@ int WindowAdapter::resolveCommand(const std::string& command) const
 }
 
 } // namespace nethack_mcp
+
+extern "C" void nethack_mcp_end_result(int how)
+{
+    nethack_mcp::WindowAdapter::reportTerminalResult(how);
+}
diff --git a/web/home.css b/web/home.css
new file mode 100644
index 0000000..c4a3735
--- /dev/null
+++ b/web/home.css
@@ -0,0 +1,36 @@
+body {
+  color: #222;
+  font: 16px system-ui, sans-serif;
+  margin: 3rem auto;
+  max-width: 1100px;
+  padding: 0 1rem;
+}
+
+table {
+  border-collapse: collapse;
+  width: 100%;
+}
+
+th,
+td {
+  border-bottom: 1px solid #ccc;
+  padding: .55rem;
+  text-align: left;
+}
+
+a {
+  color: #154b37;
+}
+
+code {
+  overflow-wrap: anywhere;
+}
+
+dt {
+  font-weight: bold;
+  margin-top: 1rem;
+}
+
+dd {
+  margin: .25rem 0;
+}
diff --git a/web/home.html b/web/home.html
new file mode 100644
index 0000000..2fc24e5
--- /dev/null
+++ b/web/home.html
@@ -0,0 +1,35 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Public NetHack games</title>
+  <link rel="stylesheet" href="/home.css">
+</head>
+<body>
+  <main>
+    <h1>Public NetHack games</h1>
+    <p>Agents play through the shared MCP endpoint. Spectators can watch
+       live games and browse completed records.</p>
+    <p>MCP endpoint: <code>{{MCP_URL}}</code></p>
+    <h2>Ten most recent completed games</h2>
+    <table>
+      <thead>
+        <tr>
+          <th>Character</th>
+          <th>Model</th>
+          <th>Started</th>
+          <th>Ended</th>
+          <th>Outcome</th>
+          <th>Lowest floor</th>
+          <th>End floor</th>
+        </tr>
+      </thead>
+      <tbody>
+        {{RECENT_GAMES}}
+      </tbody>
+    </table>
+  </main>
+  <script src="/local-time.js"></script>
+</body>
+</html>
diff --git a/web/local_time.js b/web/local_time.js
new file mode 100644
index 0000000..7a6faa3
--- /dev/null
+++ b/web/local_time.js
@@ -0,0 +1,9 @@
+for(const node of document.querySelectorAll(".local-time"))
+{
+    const seconds = Number(node.dataset.unix);
+    node.textContent = new Intl.DateTimeFormat(undefined, {
+        dateStyle: "medium",
+        timeStyle: "short",
+        timeZoneName: "short",
+    }).format(new Date(seconds * 1000));
+}
diff --git a/web/record.html b/web/record.html
new file mode 100644
index 0000000..8c73619
--- /dev/null
+++ b/web/record.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Completed NetHack game</title>
+  <link rel="stylesheet" href="/home.css">
+</head>
+<body>
+  <main>
+    <p><a href="/">Recent games</a></p>
+    <h1>{{CHARACTER_NAME}}</h1>
+    <dl>
+      <dt>Game ID</dt><dd><code>{{GAME_ID}}</code></dd>
+      <dt>Model</dt><dd>{{MODEL_SLUG}}</dd>
+      <dt>Started</dt><dd>{{STARTED_AT}}</dd>
+      <dt>{{ENDED_TIME_LABEL}}</dt><dd>{{ENDED_AT}}</dd>
+      <dt>Outcome</dt><dd>{{END_LABEL}}</dd>
+      <dt>Lowest floor visited</dt><dd>{{DEEPEST_DEPTH}}</dd>
+      <dt>Ending floor</dt><dd>{{LAST_DEPTH}}</dd>
+    </dl>
+  </main>
+  <script src="/local-time.js"></script>
+</body>
+</html>
diff --git a/web/viewer.js b/web/viewer.js
index 190ac0d..30b669a 100644
--- a/web/viewer.js
+++ b/web/viewer.js
@@ -1,5 +1,10 @@
 (() => {
     let etag = "";
+    const path_match = window.location.pathname.match(
+        /^\/g\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/);
+    const game_id = path_match ? path_match[1] : "";
+    const state_url = game_id
+        ? `/api/games/${game_id}/state` : "";
     const lifecycle = document.querySelector("#lifecycle");
     const map = document.querySelector("#map");
     const status = document.querySelector("#status");
@@ -115,12 +120,27 @@
 
     async function poll() {
         let retry_delay = 0;
+        if(!game_id)
+        {
+            lifecycle.textContent = "Invalid game URL";
+            return;
+        }
         try
         {
             const headers = etag ? {"If-None-Match": etag} : {};
-            const response = await fetch("/api/state", {
+            const response = await fetch(state_url, {
                 headers, cache: "no-store",
             });
+            if(response.status === 410)
+            {
+                window.location.replace(`/g/${game_id}`);
+                return;
+            }
+            if(response.status === 404)
+            {
+                lifecycle.textContent = "Game not found";
+                return;
+            }
             if(response.status !== 304)
             {
                 if(!response.ok) throw new Error(`HTTP ${response.status}`);