Files
faabandClaude Opus 5.5 ac35e3ff2c Add CLAUDE.md
Commands, the isolated headless-server test setup, and the architecture
that spans the mod, MCP server and /ask helper: UDP request flow and
chunking, deferred replies, multiplayer determinism, the read-only
contract, Lua-to-JSON quirks and the token-budget conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:44:52 +01:00

6.4 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

A read-only Factorio 2.x advisor. Claude looks at the user's live game and explains or suggests; it never plays for them. Three pieces talk over UDP on localhost (no RCON, so it works in singleplayer):

  • mod/claude-co-engineer/ — the in-game mod. All queries are answered in control.lua.
  • server/claude_co_engineer/server.py — the MCP server (claude-co-engineer-mcp), registered in Claude Code/Desktop under the key factorio, so tools appear as mcp__factorio__<tool>.
  • server/claude_co_engineer/ask.py — the /ask helper (claude-co-engineer-ask, a systemd user service). It runs claude -p with only this MCP server and posts the answer to the game chat.

Commands

uv sync --directory server                                   # create/refresh server/.venv
uv run --directory server claude-co-engineer-mcp             # MCP server on stdio
luac -p mod/claude-co-engineer/*.lua                         # Lua syntax check
luac -l -p mod/claude-co-engineer/control.lua | grep -oE '_ENV "[a-z_]+"' | sort -u   # globals used
systemctl --user restart claude-co-engineer-ask              # after changing ask.py/server.py
journalctl --user -u claude-co-engineer-ask -f               # /ask questions and answers

There are no tests or linters. The globals check matters: anything besides Factorio/Lua globals (game, helpers, storage, defines, prototypes, …) is a local used before it's defined in control.lua, which is nil at runtime.

Call a mod op directly, bypassing MCP (the game must be running with --enable-lua-udp 34200):

server/.venv/bin/python -c "import asyncio; from claude_co_engineer.game_link import GameLink; print(asyncio.run(GameLink(34200).request('ping')))"

The Factorio API reference is installed locally: ~/.steam/steam/steamapps/common/Factorio/doc-html/runtime-api.json. Check method and field names there; many changed in 2.0/2.1 (e.g. recipe categories, get_contents() returning arrays).

Testing without touching the user's save

Run the game binary as a headless server with its own write-data and mod directory (-c config.ini with [path] write-data=…, --mod-directory containing a symlink to the mod), created with --create test.zip and started with --start-server test.zip --enable-lua-udp <port>. Its --server-settings must set auto_pause: false (build it from data/server-settings.example.json; missing keys make the server refuse to start). Otherwise ticks stop with nobody connected and no query is answered. Headless servers can't take screenshots.

Reloading changes: control.lua changes need a save reload; settings.lua/info.json changes need a full game restart. MCP server changes need a Claude Desktop restart or a new Claude Code session; /ask starts a fresh MCP server per question.

Architecture

Request flow. GameLink (game_link.py) sends {"id", "op", "args"} JSON to the game's UDP port. The mod's on_tick calls helpers.recv_udp(). That raises on_udp_packet_received, which dispatches to handlers[op] in control.lua and replies to the packet's source port. Replies are chunked as id|seq|total|<json> (32 KB chunks, 2 MB cap) and reassembled by GameLink. Each MCP tool is a thin wrapper around one op. Op names don't always match tool names (ratio, trends, production, power, research, recipe, player, ping).

Deferred replies. A handler that needs to watch the game for a few ticks returns {__defer_ticks = n, __finish = fn} instead of a result; finish_deferred runs fn and replies later (trace_belt uses this to measure belt motion). These live in a plain local, not storage.

Multiplayer determinism. UDP packets become input actions, so handlers run on every peer. Handlers must be deterministic and only touch storage in ways every peer repeats. send_udp is called with for_player in multiplayer (only the requesting instance answers) and nil in singleplayer.

Read-only contract. Handlers only read game state. The exceptions are highlight (rendering objects, which are visual and expire) and run_lua (arbitrary code). run_lua is gated by "Allow Lua queries", and in multiplayer also by the admin-only "Allow Lua queries in multiplayer". Text from the world (station names, labels) can reach the model, which is why multiplayer Lua is off by default.

Lua→JSON quirks. helpers.table_to_json drops nil fields and encodes empty tables as [], so Python code reading results must use .get() and or {}. serialize() in control.lua turns LuaObjects into small summaries; return plain values from handlers where possible.

/ask pipeline. /ask (mod) → UDP to the helper port (mod setting, default 34201) → ask.py acks immediately (the mod warns after 3 s without an ack) → claude -p with the question on stdin, --tools "", --allowedTools mcp__factorio, --strict-mcp-config and --output-format stream-json. tool_use events become ask_status chat lines (the STATUS map) and the result becomes ask_answer. Sessions are kept per player in memory, so a helper restart forgets conversations; /ask-new resets one. The model comes from FACTORIO_ASK_MODEL_OVERRIDE, then the per-player mod setting, then FACTORIO_ASK_MODEL. The helper and GameLink only accept packets from the game's UDP port.

Conventions

  • Token budget is a design constraint. Tools are registered with the local tool() decorator (compact JSON text, no structured copy), and _compact_schema strips titles and null-unions from the advertised schemas. Keep tool descriptions to a line or two; shared conventions belong in INSTRUCTIONS. Keep outputs summarised (counts, nearest-N, merged sections) rather than exhaustive.
  • Errors that should reach the model are raised as GameError (a ToolError subclass). Other exceptions show up as a generic "Error executing tool". In Lua, expected errors use error(msg, 0) so no file:line prefix is added.
  • The MCP SDK is v2 (mcp.server.MCPServer, not FastMCP).
  • Adding a tool touches the handler in control.lua, the wrapper in server.py, the STATUS entry in ask.py, and the tools table in README.md.
  • Space Age: mineable items are treated as mined in calculate_ratios, so asteroid recipes for ore are never chosen. Recycling recipes are skipped.