BareGit

Serve NetHack MCP over HTTP and improve status display

- Host MCP and spectator routes on one loopback listener.
- Keep the game process independent of client connections and document
  the URL-based setup.
- Render status fields as readable labels, values, and conditions.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 22 14:45:27 2026 -0700
Commit: d9a6f913c7867b5030f4a140aee6a97baefc8b3a

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1e0a42d..dbfc7e9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -138,7 +138,7 @@ set(NETHACK_MCP_SOURCES
     src/mcp_server.cpp
     src/observation_store.cpp
     src/protocol.cpp
-    src/spectator_server.cpp)
+    src/game_http_server.cpp)
 
 set(STATIC_FILES
     "${CMAKE_CURRENT_SOURCE_DIR}/web/index.html"
diff --git a/README.md b/README.md
index 28ddb91..7865fd4 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 # nethack-mcp
 
 This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs it
-in a worker process, and exposes observations through MCP stdio plus a
+in a worker process, and exposes MCP over Streamable HTTP alongside a
 read-only loopback viewer.
 
 The default build uses system Lua 5.4 headers and library. CMake checks the
@@ -22,14 +22,17 @@ The produced library is:
 build/nethack-work/src/libnethack.a
 ```
 
-Run the server with:
+Start one server manually with:
 
 ```sh
 ./build/nethack_mcp --data-root /tmp/nethack-mcp --port 8765
 ```
 
-The MCP server reads newline-delimited JSON-RPC from standard input. The
-browser viewer is available at `http://127.0.0.1:8765/` and serves only
+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 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
diff --git a/include/spectator_server.hpp b/include/game_http_server.hpp
similarity index 65%
rename from include/spectator_server.hpp
rename to include/game_http_server.hpp
index f0a933f..4db25d8 100644
--- a/include/spectator_server.hpp
+++ b/include/game_http_server.hpp
@@ -11,19 +11,20 @@ namespace nethack_mcp
 {
 
 class GameSession;
+class McpServer;
 
-/// Serve immutable game observations to a loopback browser viewer.
-class SpectatorServer : public mw::HTTPServer
+/// Serve MCP and browser observations on one loopback listener.
+class GameHttpServer : public mw::HTTPServer
 {
 public:
     /// Construct a server bound to the loopback address and requested port.
-    SpectatorServer(GameSession& session, int port);
+    GameHttpServer(GameSession& session, McpServer& mcp, int port);
 
     /// Stop the listener before destroying the server.
-    ~SpectatorServer();
+    ~GameHttpServer();
 
-    SpectatorServer(const SpectatorServer&) = delete;
-    SpectatorServer& operator=(const SpectatorServer&) = delete;
+    GameHttpServer(const GameHttpServer&) = delete;
+    GameHttpServer& operator=(const GameHttpServer&) = delete;
 
     /// Bind synchronously so an occupied port fails promptly.
     bool startServer(std::string& error);
@@ -31,8 +32,11 @@ public:
     /// Stop the listener and join its serving thread.
     void stopServer();
 
+    /// Report whether the HTTP listener is still serving.
+    bool running() const;
+
 protected:
-    /// Register the read-only viewer routes.
+    /// Register the MCP and viewer routes.
     void setup() override;
 
 private:
@@ -45,8 +49,12 @@ private:
     void serveFont(const Request& request, Response& response);
     void serveState(const Request& request, Response& response);
     void serveHealth(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;
 
     GameSession& session_;
+    McpServer& mcp_;
     int port_;
     std::thread server_thread_;
     std::atomic<bool> started_ = false;
diff --git a/include/mcp_server.hpp b/include/mcp_server.hpp
index 0066fba..9dee1b7 100644
--- a/include/mcp_server.hpp
+++ b/include/mcp_server.hpp
@@ -1,24 +1,22 @@
 #pragma once
 
-#include <iosfwd>
-
 #include "game_session.hpp"
 
 namespace nethack_mcp
 {
 
-/// Implement the MCP JSON-RPC stdio transport for one game session.
+/// Dispatch MCP JSON-RPC messages for one game session.
 class McpServer
 {
 public:
     /// Bind the protocol dispatcher to a session.
     explicit McpServer(GameSession& session);
 
-    /// Read requests until stdio reaches EOF.
-    int run(std::istream& input, std::ostream& output);
+    /// Dispatch one HTTP transport message.
+    /// Set should_respond when the client expects a JSON-RPC reply.
+    Json handleMessage(const Json& request, bool& should_respond);
 
 private:
-    Json dispatch(const Json& request, bool& should_respond);
     Json tools() const;
     Json toolResult(const ToolResult& result) const;
     Json jsonRpcError(const Json& id, int code,
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
new file mode 100644
index 0000000..c28550d
--- /dev/null
+++ b/src/game_http_server.cpp
@@ -0,0 +1,286 @@
+#include "game_http_server.hpp"
+
+#include "embedded_assets.hpp"
+#include "game_session.hpp"
+#include "mcp_server.hpp"
+
+#include <string>
+#include <string_view>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+constexpr std::size_t MAX_MCP_MESSAGE_SIZE = 1024U * 1024U;
+
+const EmbeddedAsset* findAsset(std::string_view path)
+{
+    for(const auto& asset : embeddedAssets())
+    {
+        if(asset.path == path)
+        {
+            return &asset;
+        }
+    }
+    return nullptr;
+}
+
+} // 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()
+{
+    stopServer();
+}
+
+bool GameHttpServer::startServer(std::string& error)
+{
+    setup();
+    if(!server.bind_to_port("127.0.0.1", port_))
+    {
+        error = "could not bind HTTP server to 127.0.0.1:" +
+            std::to_string(port_);
+        return false;
+    }
+    started_ = true;
+    server_thread_ = std::thread([this] {
+        server.listen_after_bind();
+        started_ = false;
+    });
+    return true;
+}
+
+void GameHttpServer::stopServer()
+{
+    server.stop();
+    if(server_thread_.joinable())
+    {
+        server_thread_.join();
+    }
+    started_ = false;
+}
+
+bool GameHttpServer::running() const
+{
+    return started_;
+}
+
+void GameHttpServer::setup()
+{
+    server.set_payload_max_length(MAX_MCP_MESSAGE_SIZE);
+    server.Post("/mcp", [this](const Request& request,
+                                Response& response) {
+        serveMcp(request, response);
+    });
+    server.Get("/mcp", [this](const Request& request,
+                               Response& response) {
+        rejectMcpStream(request, response);
+    });
+    server.Delete("/mcp", [this](const Request& request,
+                                  Response& response) {
+        rejectMcpStream(request, response);
+    });
+    server.Get("/", [this](const Request& request, Response& response) {
+        servePage(request, response);
+    });
+    server.Get("/viewer.js", [this](const Request& request,
+                                      Response& response) {
+        serveScript(request, response);
+    });
+    server.Get("/viewer.css", [this](const Request& request,
+                                       Response& response) {
+        serveStyle(request, response);
+    });
+    server.Get("/viewer-font.ttf", [this](const Request& request,
+                                             Response& response) {
+        serveFont(request, response);
+    });
+    server.Get("/api/state", [this](const Request& request,
+                                      Response& response) {
+        serveState(request, response);
+    });
+    server.Get("/health", [this](const Request& request,
+                                   Response& response) {
+        serveHealth(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_);
+}
+
+bool GameHttpServer::validOrigin(const Request& request) const
+{
+    if(!request.has_header("Origin"))
+    {
+        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_);
+}
+
+void GameHttpServer::serveMcp(const Request& request, Response& response)
+{
+    if(!validHost(request) || !validOrigin(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const std::string content_type = request.get_header_value("Content-Type");
+    if(content_type != "application/json"
+       && content_type.rfind("application/json;", 0) != 0)
+    {
+        response.status = 415;
+        return;
+    }
+    if(request.body.size() > MAX_MCP_MESSAGE_SIZE)
+    {
+        response.status = 413;
+        return;
+    }
+    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")
+    {
+        response.status = 400;
+        return;
+    }
+
+    Json message;
+    try
+    {
+        message = Json::parse(request.body);
+    }
+    catch(const Json::parse_error& exception)
+    {
+        response.status = 400;
+        response.set_content(Json({
+            {"jsonrpc", "2.0"},
+            {"id", nullptr},
+            {"error", {
+                {"code", -32700}, {"message", exception.what()},
+            }},
+        }).dump(), "application/json; charset=utf-8");
+        return;
+    }
+
+    if(message.is_object() && message.contains("jsonrpc")
+       && message.at("jsonrpc").is_string()
+       && message.at("jsonrpc") == "2.0"
+       && !message.contains("method") && message.contains("id")
+       && (message.contains("result") || message.contains("error")))
+    {
+        response.status = 202;
+        return;
+    }
+
+    bool should_respond = true;
+    const Json reply = mcp_.handleMessage(message, should_respond);
+    if(!should_respond)
+    {
+        response.status = 202;
+        return;
+    }
+    response.set_content(reply.dump(), "application/json; charset=utf-8");
+}
+
+void GameHttpServer::rejectMcpStream(const Request& request,
+                                      Response& response)
+{
+    if(!validHost(request) || !validOrigin(request))
+    {
+        response.status = 403;
+        return;
+    }
+    response.set_header("Allow", "POST");
+    response.status = 405;
+}
+
+void GameHttpServer::serveStatic(std::string_view path,
+                                  const Request& request,
+                                  Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const EmbeddedAsset* asset = findAsset(path);
+    if(asset == nullptr)
+    {
+        response.status = 404;
+        return;
+    }
+    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);
+}
+
+void GameHttpServer::serveScript(const Request& request,
+                                  Response& response)
+{
+    serveStatic("/viewer.js", request, response);
+}
+
+void GameHttpServer::serveStyle(const Request& request, Response& response)
+{
+    serveStatic("/viewer.css", request, response);
+}
+
+void GameHttpServer::serveFont(const Request& request, Response& response)
+{
+    serveStatic("/viewer-font.ttf", request, response);
+}
+
+void GameHttpServer::serveState(const Request& request, Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const Json state = session_.snapshot();
+    const std::string etag = "\"" +
+        std::to_string(state.value("revision", 0ULL)) + "\"";
+    response.set_header("ETag", etag);
+    if(request.get_header_value("If-None-Match") == etag)
+    {
+        response.status = 304;
+        return;
+    }
+    response.set_content(state.dump(), "application/json; charset=utf-8");
+}
+
+void GameHttpServer::serveHealth(const Request& request, Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const Json state = session_.snapshot();
+    response.set_content(Json({
+        {"ready", true},
+        {"lifecycle", state.value("lifecycle", "idle")},
+        {"viewer_url", session_.viewerUrl()},
+    }).dump(), "application/json; charset=utf-8");
+}
+
+} // namespace nethack_mcp
diff --git a/src/main.cpp b/src/main.cpp
index 8d78de3..8352951 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,18 +1,27 @@
 #include "engine_worker.hpp"
+#include "game_http_server.hpp"
 #include "game_session.hpp"
 #include "mcp_server.hpp"
-#include "spectator_server.hpp"
 
 #include <cstdio>
 #include <cstdlib>
+#include <chrono>
+#include <csignal>
 #include <exception>
 #include <filesystem>
-#include <iostream>
 #include <string>
+#include <thread>
 
 namespace
 {
 
+volatile std::sig_atomic_t stop_requested = 0;
+
+void requestStop(int)
+{
+    stop_requested = 1;
+}
+
 struct Options
 {
     std::filesystem::path data_root;
@@ -81,19 +90,29 @@ int main(int argc, char* argv[])
             options.data_root,
             NETHACK_RUNTIME_DIR,
             "http://127.0.0.1:" + std::to_string(options.port) + "/");
-        nethack_mcp::SpectatorServer spectator(session, options.port);
+        nethack_mcp::McpServer mcp(session);
+        nethack_mcp::GameHttpServer http_server(session, mcp,
+                                               options.port);
         std::string error;
-        if(!spectator.startServer(error))
+        if(!http_server.startServer(error))
         {
             std::fprintf(stderr, "%s\n", error.c_str());
             return 1;
         }
-        std::fprintf(stderr, "Spectator: %s\n", session.viewerUrl().c_str());
-
-        nethack_mcp::McpServer server(session);
-        const int result = server.run(std::cin, std::cout);
-        spectator.stopServer();
-        return result;
+        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)
     {
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
index 08bcf46..a02a9a7 100644
--- a/src/mcp_server.cpp
+++ b/src/mcp_server.cpp
@@ -1,7 +1,5 @@
 #include "mcp_server.hpp"
 
-#include <cstddef>
-#include <iostream>
 #include <string>
 #include <utility>
 
@@ -11,14 +9,11 @@ namespace nethack_mcp
 namespace
 {
 
-constexpr std::size_t MAX_STDIO_MESSAGE_SIZE = 1024U * 1024U;
-
 bool supportedProtocolVersion(const std::string& version)
 {
     return version == "2025-11-25"
         || version == "2025-06-18"
-        || version == "2025-03-26"
-        || version == "2024-11-05";
+        || version == "2025-03-26";
 }
 
 Json objectSchema(Json properties, Json required = Json::array())
@@ -51,43 +46,7 @@ McpServer::McpServer(GameSession& session)
         : session_(session)
 {}
 
-int McpServer::run(std::istream& input, std::ostream& output)
-{
-    std::string line;
-    while(std::getline(input, line))
-    {
-        if(line.size() > MAX_STDIO_MESSAGE_SIZE)
-        {
-            output << jsonRpcError(nullptr, -32600,
-                                   "JSON-RPC message exceeds 1 MiB")
-                   .dump() << '\n' << std::flush;
-            continue;
-        }
-
-        Json request;
-        try
-        {
-            request = Json::parse(line);
-        }
-        catch(const Json::parse_error& exception)
-        {
-            output << jsonRpcError(nullptr, -32700, exception.what()).dump()
-                   << '\n' << std::flush;
-            continue;
-        }
-
-        bool should_respond = true;
-        Json response = dispatch(request, should_respond);
-        if(should_respond)
-        {
-            output << response.dump() << '\n' << std::flush;
-        }
-    }
-    session_.shutdown();
-    return 0;
-}
-
-Json McpServer::dispatch(const Json& request, bool& should_respond)
+Json McpServer::handleMessage(const Json& request, bool& should_respond)
 {
     should_respond = true;
     if(!request.is_object()
diff --git a/src/spectator_server.cpp b/src/spectator_server.cpp
deleted file mode 100644
index df3cd91..0000000
--- a/src/spectator_server.cpp
+++ /dev/null
@@ -1,175 +0,0 @@
-#include "spectator_server.hpp"
-
-#include "embedded_assets.hpp"
-#include "game_session.hpp"
-
-#include <string>
-#include <string_view>
-
-namespace nethack_mcp
-{
-
-namespace
-{
-
-const EmbeddedAsset* findAsset(std::string_view path)
-{
-    for(const auto& asset : embeddedAssets())
-    {
-        if(asset.path == path)
-        {
-            return &asset;
-        }
-    }
-    return nullptr;
-}
-
-} // namespace
-
-SpectatorServer::SpectatorServer(GameSession& session, int port)
-        : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", port}),
-          session_(session), port_(port)
-{}
-
-SpectatorServer::~SpectatorServer()
-{
-    stopServer();
-}
-
-bool SpectatorServer::startServer(std::string& error)
-{
-    setup();
-    if(!server.bind_to_port("127.0.0.1", port_))
-    {
-        error = "could not bind spectator server to 127.0.0.1:" +
-            std::to_string(port_);
-        return false;
-    }
-    started_ = true;
-    server_thread_ = std::thread([this] {
-        server.listen_after_bind();
-        started_ = false;
-    });
-    return true;
-}
-
-void SpectatorServer::stopServer()
-{
-    server.stop();
-    if(server_thread_.joinable())
-    {
-        server_thread_.join();
-    }
-    started_ = false;
-}
-
-void SpectatorServer::setup()
-{
-    server.Get("/", [this](const Request& request, Response& response) {
-        servePage(request, response);
-    });
-    server.Get("/viewer.js", [this](const Request& request,
-                                      Response& response) {
-        serveScript(request, response);
-    });
-    server.Get("/viewer.css", [this](const Request& request,
-                                       Response& response) {
-        serveStyle(request, response);
-    });
-    server.Get("/viewer-font.ttf", [this](const Request& request,
-                                             Response& response) {
-        serveFont(request, response);
-    });
-    server.Get("/api/state", [this](const Request& request,
-                                      Response& response) {
-        serveState(request, response);
-    });
-    server.Get("/health", [this](const Request& request,
-                                   Response& response) {
-        serveHealth(request, response);
-    });
-}
-
-bool SpectatorServer::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_);
-}
-
-void SpectatorServer::serveStatic(std::string_view path,
-                                  const Request& request,
-                                  Response& response)
-{
-    if(!validHost(request))
-    {
-        response.status = 403;
-        return;
-    }
-    const EmbeddedAsset* asset = findAsset(path);
-    if(asset == nullptr)
-    {
-        response.status = 404;
-        return;
-    }
-    response.set_content(asset->content.data(), asset->content.size(),
-                         std::string(asset->content_type));
-}
-
-void SpectatorServer::servePage(const Request& request, Response& response)
-{
-    serveStatic("/", request, response);
-}
-
-void SpectatorServer::serveScript(const Request& request,
-                                  Response& response)
-{
-    serveStatic("/viewer.js", request, response);
-}
-
-void SpectatorServer::serveStyle(const Request& request, Response& response)
-{
-    serveStatic("/viewer.css", request, response);
-}
-
-void SpectatorServer::serveFont(const Request& request, Response& response)
-{
-    serveStatic("/viewer-font.ttf", request, response);
-}
-
-void SpectatorServer::serveState(const Request& request, Response& response)
-{
-    if(!validHost(request))
-    {
-        response.status = 403;
-        return;
-    }
-    const Json state = session_.snapshot();
-    const std::string etag = "\"" +
-        std::to_string(state.value("revision", 0ULL)) + "\"";
-    response.set_header("ETag", etag);
-    if(request.get_header_value("If-None-Match") == etag)
-    {
-        response.status = 304;
-        return;
-    }
-    response.set_content(state.dump(), "application/json; charset=utf-8");
-}
-
-void SpectatorServer::serveHealth(const Request& request, Response& response)
-{
-    if(!validHost(request))
-    {
-        response.status = 403;
-        return;
-    }
-    const Json state = session_.snapshot();
-    response.set_content(Json({
-        {"ready", true},
-        {"lifecycle", state.value("lifecycle", "idle")},
-        {"viewer_url", session_.viewerUrl()},
-    }).dump(), "application/json; charset=utf-8");
-}
-
-} // namespace nethack_mcp
diff --git a/web/index.html b/web/index.html
index 4cb00d2..57a6f0d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,7 +15,7 @@
     <pre id="map" aria-label="NetHack map"></pre>
     <section>
       <h2>Status</h2>
-      <pre id="status"></pre>
+      <dl id="status" aria-live="polite"></dl>
     </section>
     <section>
       <h2>Pending input</h2>
diff --git a/web/viewer.css b/web/viewer.css
index 9fc6e91..58d7424 100644
--- a/web/viewer.css
+++ b/web/viewer.css
@@ -30,3 +30,16 @@ pre { overflow: auto; }
 }
 section { margin-top: 1rem; }
 #messages { max-height: 16rem; overflow: auto; }
+#status {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+  gap: 0.5rem;
+  margin: 0;
+}
+.status-field {
+  border: 1px solid #555;
+  padding: 0.5rem 0.75rem;
+}
+.status-field dt { color: #aaa; font-size: 0.85rem; }
+.status-field dd { margin: 0.2rem 0 0; overflow-wrap: anywhere; }
+.status-empty { margin: 0; color: #aaa; }
diff --git a/web/viewer.js b/web/viewer.js
index ccd8453..7aed8c1 100644
--- a/web/viewer.js
+++ b/web/viewer.js
@@ -1,38 +1,136 @@
 (() => {
-  let etag = "";
-  const lifecycle = document.querySelector("#lifecycle");
-  const map = document.querySelector("#map");
-  const status = document.querySelector("#status");
-  const pending = document.querySelector("#pending");
-  const messages = document.querySelector("#messages");
-
-  function show(value) {
-    lifecycle.textContent = value.lifecycle || "unknown";
-    map.textContent = (value.map && value.map.rows || []).join("\n");
-    status.textContent = JSON.stringify(value.status || {}, null, 2);
-    pending.textContent = value.pending
-      ? JSON.stringify(value.pending, null, 2) : "None";
-    messages.replaceChildren();
-    for (const message of value.messages || []) {
-      const item = document.createElement("li");
-      item.textContent = message.text || "";
-      messages.append(item);
+    let etag = "";
+    const lifecycle = document.querySelector("#lifecycle");
+    const map = document.querySelector("#map");
+    const status = document.querySelector("#status");
+    const pending = document.querySelector("#pending");
+    const messages = document.querySelector("#messages");
+    const STATUS_ORDER = [
+        ["title", "Character"],
+        ["dungeon_level", "Location"],
+        ["hp", "HP", "max_hp"],
+        ["power", "Power", "max_power"],
+        ["armor_class", "Armor class"],
+        ["experience_level", "Level"],
+        ["experience", "Experience"],
+        ["gold", "Gold"],
+        ["time", "Turn"],
+        ["alignment", "Alignment"],
+        ["hunger", "Hunger"],
+        ["carrying_capacity", "Encumbrance"],
+        ["strength", "Strength"],
+        ["dexterity", "Dexterity"],
+        ["constitution", "Constitution"],
+        ["intelligence", "Intelligence"],
+        ["wisdom", "Wisdom"],
+        ["charisma", "Charisma"],
+        ["score", "Score"],
+        ["weapon", "Weapon"],
+        ["armor", "Armor"],
+        ["terrain", "Terrain"],
+        ["HD", "Hit dice"],
+        ["version", "Version"],
+    ];
+
+    function displayText(field) {
+        if(field && typeof field === "object")
+        {
+            return typeof field.text === "string" ? field.text : "";
+        }
+        return typeof field === "string" ? field : "";
+    }
+
+    function appendStatusField(label, value) {
+        const item = document.createElement("div");
+        item.className = "status-field";
+        const name = document.createElement("dt");
+        name.textContent = label;
+        const text = document.createElement("dd");
+        text.textContent = value;
+        item.append(name, text);
+        status.append(item);
+    }
+
+    function renderStatus(fields) {
+        status.replaceChildren();
+        const values = fields && typeof fields === "object" ? fields : {};
+        const displayed = new Set();
+
+        for(const [key, label, maximum] of STATUS_ORDER)
+        {
+            if(!Object.hasOwn(values, key)) continue;
+            const current = displayText(values[key]);
+            if(current === "") continue;
+            let text = current;
+            if(maximum && Object.hasOwn(values, maximum))
+            {
+                const maximum_text = displayText(values[maximum]);
+                if(maximum_text !== "") text += ` / ${maximum_text}`;
+                displayed.add(maximum);
+            }
+            appendStatusField(label, text);
+            displayed.add(key);
+        }
+
+        const condition = values.condition;
+        if(condition && Array.isArray(condition.active))
+        {
+            appendStatusField("Conditions",
+                condition.active.length ? condition.active.join(", ") : "None");
+            displayed.add("condition");
+        }
+
+        for(const [key, field] of Object.entries(values))
+        {
+            if(displayed.has(key)) continue;
+            const text = displayText(field);
+            if(text === "") continue;
+            const label = key.replaceAll("_", " ");
+            appendStatusField(label[0].toUpperCase() + label.slice(1), text);
+        }
+
+        if(!status.children.length)
+        {
+            const empty = document.createElement("p");
+            empty.className = "status-empty";
+            empty.textContent = "No status available yet.";
+            status.append(empty);
+        }
     }
-  }
-
-  async function poll() {
-    try {
-      const headers = etag ? {"If-None-Match": etag} : {};
-      const response = await fetch("/api/state", {headers, cache: "no-store"});
-      if (response.status === 304) return;
-      if (!response.ok) throw new Error(`HTTP ${response.status}`);
-      etag = response.headers.get("ETag") || "";
-      show(await response.json());
-    } catch (error) {
-      lifecycle.textContent = `Disconnected: ${error.message}`;
+
+    function show(value) {
+        lifecycle.textContent = value.lifecycle || "unknown";
+        map.textContent = (value.map && value.map.rows || []).join("\n");
+        renderStatus(value.status);
+        pending.textContent = value.pending
+            ? JSON.stringify(value.pending, null, 2) : "None";
+        messages.replaceChildren();
+        for(const message of value.messages || [])
+        {
+            const item = document.createElement("li");
+            item.textContent = message.text || "";
+            messages.append(item);
+        }
+    }
+
+    async function poll() {
+        try
+        {
+            const headers = etag ? {"If-None-Match": etag} : {};
+            const response = await fetch("/api/state", {
+                headers, cache: "no-store",
+            });
+            if(response.status === 304) return;
+            if(!response.ok) throw new Error(`HTTP ${response.status}`);
+            etag = response.headers.get("ETag") || "";
+            show(await response.json());
+        }
+        catch(error)
+        {
+            lifecycle.textContent = `Disconnected: ${error.message}`;
+        }
     }
-  }
 
-  poll();
-  setInterval(poll, 250);
+    poll();
+    setInterval(poll, 250);
 })();