@skyphusion/hollow-grid-bot
v1.2.1
Published
AI player for The Hollow Grid MUD: WebSocket client with structured @event state and pluggable LLM brains.
Downloads
193
Maintainers
Readme
Hollow Grid bot
The only bot in this repository. An AI player for The Hollow Grid
(Conrad's MUD on Cloudflare Workers; world engine in the separate the-hollow-grid
repo). Everything else that once lived alongside it (the Discord-to-ollama relay
and the Packet Wastes Python tooling) was removed from this repository.
It connects like any other client (WebSocket to /ws, first line = character name), reads the structured
@event channel for exact game state (the same lines smoke.mjs asserts on),
and asks a model for the next command. Deterministic survival reflexes (rest when
hurt, ride out combat) run before the model so it never burns a round, or its
life, on the obvious calls.
Single file, no build step and no dependencies (uses Node's global
WebSocket + fetch). Requires Node 24+.
Brains (pluggable via BOT_BRAIN)
ollama(default) -- a free local model.anthropic-- the Anthropic API (billed per call).gateway-- any provider through a Cloudflare AI Gateway (OpenAI-compatible); the bot holds only a gateway token, provider keys stay in the Gateway (BYOK). This includes open-source models on Cloudflare Workers AI viaMUD_MODEL=workers-ai/@cf/<model>; under Unified Billing the gateway token is the only credential the bot needs (no BYOK, no provider key). See below.
Provider fallback chain (always-on fleet)
For the always-on fleet population (issue #35), set BOT_PROVIDERS to an ordered,
comma-separated chain instead of a single BOT_BRAIN:
# local ollama primary, Cloudflare Workers AI REST fallback
BOT_PROVIDERS=ollama,workersai \
OLLAMA_BASE_URL=http://10.1.1.7:11434/v1 OLLAMA_MODEL=qwen2.5:14b-instruct-q4_K_M \
WORKERS_AI_TOKEN=... CF_ACCOUNT_ID=... \
WORKERS_AI_MODEL=@cf/meta/llama-3.3-70b-instruct-fp8-fast \
MUD_URL=wss://hollow.skyphusion.org/ws node bot.mjsollama, Workers AI, and the AI Gateway all speak the same OpenAI-compatible
chat/completions shape, so both the prompt and the response parse are identical across
providers. A per-provider circuit breaker (BOT_CB_FAILS, default 2; BOT_CB_COOLDOWN_MS,
default 30000) flips traffic automatically in BOTH directions: when the local primary is
preempted (vivijure stops the GPU container at will, which is normal) it falls through to
the fallback, and once a gentle health probe re-tests the primary and passes, it returns to
local on its own. No human action either way. If every provider is unreachable, the bot
idles quietly on a canned, non-LLM move. Provider flips are logged at info level with a
timestamp (grep PROVIDER FLIP). BOT_PROVIDERS unset keeps the single-BOT_BRAIN
behavior below unchanged.
Run
# against a local dev world (the-hollow-grid: npm run dev) on ollama
node bot.mjs
# against live prod
MUD_URL=wss://hollow.skyphusion.org/ws node bot.mjs
# on Claude via the Anthropic API
BOT_BRAIN=anthropic ANTHROPIC_API_KEY=sk-... node bot.mjs
# via a Cloudflare AI Gateway (keys stay in Cloudflare)
BOT_BRAIN=gateway CF_AIG_TOKEN=... CF_ACCOUNT_ID=... CF_AIG_GATEWAY=skyphusion-llm \
MUD_MODEL=anthropic/claude-sonnet-4-6 node bot.mjs
# open-source model on Cloudflare Workers AI (gateway token only, no BYOK)
BOT_BRAIN=gateway CF_AIG_TOKEN=... CF_ACCOUNT_ID=... CF_AIG_GATEWAY=skyphusion-llm \
MUD_MODEL=workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast node bot.mjsFull env config (MUD_NAME, MUD_MODEL, BOT_THINK_MS, the gateway/anthropic
knobs, BOT_LOG, MUD_WORLD_URLS, MUD_WORLD_ALIASES, ...) is documented in the
header comment of bot.mjs.
Tests
bot.test.mjs is a dependency-free suite on Node's built-in node:test runner.
It exercises the pure core of the bot: @event ingestion and state rebuild, the
SSRF-safe world registry and grid.travel handling, command sanitizing, the
survival reflexes (rest, stuck-combat watchdog), loop breaking, the QA
bug-reporting side-channel, and all three brains against a stubbed fetch (no
network). bot.mjs only starts playing when executed directly, so the suite can
import it without side effects.
npm test # plain run
npm run test:coverage # the CI gate: fails under 75% line/branch/function coverage on bot.mjsThe release workflow runs both the lint (node --check) and the coverage gate on
every push and PR; the GHCR image build on a v* tag depends on them passing.
Grid travel (SSRF-safe)
The bot never dials server-supplied URLs on grid.travel. It maps the world name
(data.to) to ws endpoints configured at startup via MUD_WORLD_URLS /
MUD_WORLD_ALIASES. Code scanning uses GitHub CodeQL default setup.
The anthropic/gateway brains bill continuously while the bot runs (it acts every
few seconds); pick the model and BOT_THINK_MS accordingly.
Open-source models on Cloudflare Workers AI
The gateway brain drives any Workers AI
open-source model with no code change -- set BOT_BRAIN=gateway and
MUD_MODEL=workers-ai/@cf/<model>. The AI Gateway's OpenAI-compatible /compat
endpoint is what bot.mjs already speaks; the workers-ai/ prefix just selects
the provider. Under Unified Billing the bot needs only a gateway token (sent as
cf-aig-authorization), so there are no provider keys in the container.
Two models validated against this bot with live play on The Hollow Grid:
| Model | Style | BOT_MAX_TOKENS |
|-------|-------|------------------|
| @cf/meta/llama-3.3-70b-instruct-fp8-fast | non-reasoning; terse, decisive single commands | 40 is enough |
| @cf/qwen/qwen3-30b-a3b-fp8 | reasoning; "thinks out loud", surfaced in the logs | 2000+ (see gotcha) |
Reasoning-model gotcha. The default BOT_MAX_TOKENS=40 is fine for a
non-reasoning model (one short command), but a reasoning model spends that whole
budget thinking and emits no command -- the call comes back empty and the bot
falls back to look. Raise BOT_MAX_TOKENS (~2000) for reasoning models so the
chain-of-thought plus the final command both fit. Through Workers AI the reasoning
is returned in a separate message.reasoning field (not inline in
message.content). Since v1.0.3, when content is empty the bot extracts a
one-line command from reasoning (backticks, labeled picks, race names) before
falling back to look.
Browse the catalog with the Cloudflare API
(GET /accounts/{id}/ai/models/search?task=Text%20Generation) or the dashboard;
prefer instruction-tuned models, since the bot needs a single short command per turn.
Laptop external QA (Docker Desktop)
Same image as fleet (mud-bots-hg:v1.0.3), against the public Rust Choir URL
(wss://rustchoir.skyphusion.org/ws). Gateway creds live in ~/mud-bots-gateway.env
(source from ~/.zshrc).
cd hollow-grid
docker compose -f compose.laptop.yaml up
# logs: local/logs/local-scrape.log
# bug JSONL: local/logs/local-scrape-bugs.jsonlOverride cred path: MUD_BOTS_GATEWAY_ENV=/path/to/env docker compose -f compose.laptop.yaml up
Container image (GHCR)
CI builds and pushes on every v* git tag (see root CHANGELOG.md):
ghcr.io/skyphusion-labs/mud-bots-hg:v1.0.3
ghcr.io/skyphusion-labs/mud-bots-hg:latestMinimal runtime: Node 24 slim, single file bot.mjs, no deps. Example one-shot:
docker run --rm \
-e BOT_BRAIN=gateway \
-e MUD_URL=wss://hollow.skyphusion.org/ws \
-e MUD_NAME=MyBot \
-e CF_ACCOUNT_ID=... \
-e CF_AIG_GATEWAY=skyphusion-llm \
-e CF_AIG_TOKEN=... \
-e MUD_MODEL=workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast \
ghcr.io/skyphusion-labs/mud-bots-hg:v1.0.3Federation travel: set MUD_WORLD_URLS and MUD_WORLD_ALIASES (see bot.mjs
header). Optional scheduled cross-world load (v1.0.4+):
BOT_TRAVEL_INTERVAL_MS=300000 # 5 minutes
BOT_TRAVEL_TARGETS=Dustfall,The Hollow GridFleet reference compose + bot → world deployment matrix:
fleet-chezmoi/system/stacks/biafra/mud-bots/README.md.
Deployment
Outbound-only Node containers on the operator host, driven by open-source models on
Cloudflare Workers AI through the skyphusion-llm AI Gateway (Unified Billing): no
GPU box, no ollama sidecar. Each bot holds its own AI-Gateway-Run-scoped token
(per-function keys, independently revocable). Pin v1.0.3 (or latest) in compose;
redeploy with docker compose pull && docker compose up -d.
Provenance
This is the reconciliation of three diverged copies of bot.mjs (see the repo
history), all merged here:
- the
room.actionsconsumption / affordance layer from the world repo'smain(the bot reads the server's enumerated valid verbs, with moral valence, and prefers them over guessing), - the dead-dial fallback (a
travelhandoff to a silent world no longer hangs the bot; after a few dud dials it falls back to the home grid), - and the free-over-loot prompt weighting (freeing caged/captive people is the point of the world, not the loot near them; combat is tick-based).
