18 KiB
Claude Player API
Status: Phase 0 design draft, p2-67.
Owner: simulator-infra.
Wire: JSON-Lines over stdin/stdout (one JSON value per line, \n terminator).
Encoding: UTF-8 strict, no BOM, no trailing whitespace.
Purpose
Let a Claude Agent SDK process play one slot of a Magic Civilization game while
the production AI plays the others. Every action Claude takes maps 1-to-1 to a
button or interaction the human UI exposes — no privileged shortcuts, no
direct GameState mutation. Game state is read via a fog-aware
projection so Claude only sees what the player it's bound to could see.
Wire protocol
Framing
JSON-Lines. Each message is a single JSON value followed by \n. The harness
treats \r\n as \n. Any line that fails to parse triggers a protocol_error
notification and is otherwise ignored. No length prefixes, no envelopes
beyond the single JSON value.
Direction
Adapter ──Request──▶ Harness
◀─Response─
◀─Notify── (event broadcasts; many per turn)
Requests and responses are correlated by an optional client-supplied id
field. If the adapter omits id, the harness echoes null.
Message shapes
Request (adapter → harness):
{"id": 42, "type": "view"}
{"id": 43, "type": "act", "action": {"type": "move", "unit_id": "u_1", "to": [5, 7]}}
{"id": 44, "type": "act", "action": {"type": "end_turn"}}
{"id": 45, "type": "shutdown"}
Response (harness → adapter):
{"id": 42, "ok": true, "view": { ... }}
{"id": 43, "ok": true, "events": [ ... ], "view": { ... }}
{"id": 43, "ok": false, "error": {"code": "illegal_action", "message": "...", "details": {...}}}
Notification (harness → adapter, no id):
{"type": "turn_started", "turn": 12, "player": 0}
{"type": "ai_turn_completed", "player": 1, "actions_applied": 14}
{"type": "city_founded", "city_id": "clan2_capital", "owner": 1, "position": [22, 11]}
{"type": "turn_timeout", "player": 0, "elapsed_sec": 60.2}
{"type": "game_over", "winner": 0, "victory_type": "domination"}
{"type": "protocol_error", "message": "could not parse line 17: unexpected EOF"}
The harness emits one notification per EventBus signal so the adapter can
keep its world model in sync without polling. The full mapping is in
Notifications below.
Request types
type |
Body | Returns |
|---|---|---|
view |
{} |
{ ok, view } |
act |
{ action: PlayerAction } |
{ ok, events, view } (or error) |
shutdown |
{} |
{ ok: true } then closes the pipe |
view is a pure read; calling it repeatedly between actions is free. The
returned view after act is the post-action state, so adapters do not have
to call view separately after every move.
Error codes
code |
Meaning |
|---|---|
illegal_action |
Action shape is valid but action is not in legal_actions for current state |
not_your_turn |
The bound player slot is not the current player |
parse_error |
Action JSON did not deserialise into a PlayerAction |
unknown_unit / unknown_city / unknown_tech |
Referenced id not present |
target_invalid |
Move/Attack target is out-of-range, blocked, or off-map |
internal |
Underlying mc-turn returned an unexpected error (bug — file an issue) |
PlayerAction enum
Wire-tagged on a type field, snake_case variants. Defined in Rust as
mc_player_api::PlayerAction. Every variant maps to one user-visible
interaction.
// Unit actions — the unit panel + map clicks
{"type": "move", "unit_id": "u_42", "to": [5, 7]}
{"type": "attack", "unit_id": "u_42", "target": [6, 7]}
{"type": "ranged_attack", "unit_id": "u_42", "target": [9, 7]}
{"type": "fortify", "unit_id": "u_42"}
{"type": "skip", "unit_id": "u_42"}
{"type": "found_city", "unit_id": "u_42"}
{"type": "build_improvement","unit_id":"u_42", "improvement_id": "farm"}
{"type": "issue_patrol", "unit_id": "u_42", "waypoints": [[5,7],[6,8]]}
{"type": "set_rally_point", "unit_id": "u_42", "to": [10, 10]}
{"type": "command_formation","formation_id": 3, "command": "advance", "to": [12, 5]}
// ... + every other ActionKind variant — see mc-core::action::ActionKind
// City actions — city screen buttons
{"type": "queue_production","city_id": "ironhold", "item": "dwarf_warrior"}
{"type": "queue_production","city_id": "ironhold", "item": "dwarf_forge", "tile": [3, 4]}
{"type": "remove_from_queue","city_id": "ironhold", "index": 2}
{"type": "queue_reorder", "city_id": "ironhold", "from": 0, "to": 2}
{"type": "rush_buy", "city_id": "ironhold"}
{"type": "buy_tile", "city_id": "ironhold", "tile": [4, 5]}
{"type": "set_focus", "city_id": "ironhold", "focus": "production"}
{"type": "merge_buildings", "city_id": "ironhold",
"building_a": "rifle_range",
"building_b": "barding_hall",
"into": "war_academy"}
// Building actions — building panel buttons inside city screen
{"type": "building_action", "city_id": "ironhold",
"building_id": "watchtower_42",
"kind": "set_rally", "target": [8, 8]}
{"type": "building_action", "city_id": "ironhold",
"building_id": "watchtower_42", "kind": "garrison_in",
"unit_id": "u_42"}
// ... covers every BuildingActionKind variant (set_rally, garrison_in, repair,
// toggle_active, fire_arrows, raze, annex, stockpile, ...)
// Empire-level
{"type": "research_tech", "tech_id": "bronze_working"}
{"type": "research_tradition", "tradition_id": "ancestor_veneration"}
{"type": "switch_civic", "axis": "authority", "choice": "council"}
// Diplomacy — one variant per offer + response
{"type": "offer_open_borders", "to": 1}
{"type": "accept_open_borders","from": 1}
{"type": "reject_open_borders","from": 1}
{"type": "offer_shared_map", "to": 1}
{"type": "declare_war", "on": 1}
{"type": "offer_peace", "to": 1}
{"type": "respond_to_ransom", "offer_id": 17, "response": "accept"}
// Meta
{"type": "end_turn"}
{"type": "noop"} // captured for telemetry; legal but no-op
PlayerView shape
The fog-aware projection returned by view and after every successful
act. Read-only on the wire (mutations come from act).
{
"turn": 12,
"player": 0, // bound player index
"current_player": 0, // who has priority right now
"phase": "player_actions", // mirrors TurnManager.Phase
"is_human_turn": true, // false when AI has priority
"resources": {
"gold": 87,
"gold_per_turn": 4,
"science_per_turn": 6,
"culture_per_turn": 3,
"happiness_pool": 0,
"stockpile": {"iron": 4, "wood": 12, "grain": 0}
},
"research": {
"current_tech": "bronze_working",
"tech_progress": 18,
"tech_cost": 40,
"researched": ["mining", "pottery"],
"available": ["bronze_working", "calendar", "writing"]
},
"culture": {
"current_tradition": "ancestor_veneration",
"tradition_progress": 5,
"tradition_cost": 30,
"researched": []
},
"civics": {
"authority": "council",
"labor": "guild",
"economy": null,
"anarchy_turns_remaining": 0
},
"cities": [
{
"id": "ironhold",
"name": "Ironhold",
"position": [12, 5],
"owner": 0,
"is_capital": true,
"population": 5,
"food_stored": 12,
"food_growth_threshold": 30,
"production_queue": [
{"item": "dwarf_warrior", "kind": "unit", "progress": 8, "cost": 40},
{"item": "dwarf_granary", "kind": "building", "progress": 0, "cost": 60}
],
"buildings": ["dwarf_palace"],
"owned_tiles": [[12,5],[12,4],[11,5],[13,5],[12,6],[11,6],[13,4]],
"yields": {"food": 6, "production": 4, "gold": 2, "science": 1, "culture": 1},
"hp": 100, "max_hp": 100,
"focus": "balanced",
"buildable": [
{"item": "dwarf_warrior", "kind": "unit", "cost": 40, "rush_gold": 80},
{"item": "dwarf_granary", "kind": "building", "cost": 60, "rush_gold": 120}
]
}
],
"units": [
{
"id": "u_42",
"type": "dwarf_warrior",
"position": [10, 5],
"owner": 0,
"hp": 100, "max_hp": 100,
"movement_left": 2, "movement_max": 2,
"experience": 0, "promotion_available": false,
"fortified": false, "sentry": false,
"legal_actions": [
{"action": {"type": "move", "unit_id": "u_42", "to": [11, 5]}, "enabled": true},
{"action": {"type": "fortify", "unit_id": "u_42"}, "enabled": true},
{"action": {"type": "attack", "unit_id": "u_42", "target": [11, 5]},
"enabled": false, "disabled_reason": "no_enemy_in_range"}
]
}
],
"tiles": [
// Only tiles the player has explored. Hidden tiles omitted entirely
// unless CP_OMNISCIENT=1.
{"position": [12, 5], "biome": "plains", "substrate": "rock",
"improvement": null, "river": false, "explored": true, "visible": true,
"owner_city": "ironhold"}
],
"diplomacy": [
{"player": 1, "race": "dwarf", "name": "Clan Goldvein",
"relation": "peace", "open_borders": false, "shared_map": false,
"agreements_active": []}
],
"pending_events": {
"ransom_offers": [
{"offer_id": 17, "captor": 1, "captive_unit": "dwarf_worker",
"price": 80, "expires_in_turns": 2}
],
"promotion_picks": [],
"anarchy_choice": null
},
"legal_actions": [
// Top-level actions not tied to a specific unit/city: end_turn, research_tech,
// research_tradition, switch_civic, diplomacy offers, etc.
{"action": {"type": "end_turn"}, "enabled": true}
],
"score": {
"gold_total": 87, "city_count": 1, "unit_count": 2,
"score_estimate": 142
}
}
Fog-of-war redaction
When CP_OMNISCIENT=0 (default):
- Tiles: only those in
Player.observations.explored_tiles. Tiles invisible_nowcarryvisible: true; previously-seen tiles arevisible: falseand theirimprovement/unitsfields reflect the last seen observation. - Units: only own units + enemy units in current vision range. Enemy
unit
idis replaced with an opaque per-turn token if the enemy's identity isn't already known (prevents tracking). - Cities: own cities + enemy cities the player has ever seen.
populationis last-observed value, not live. - Tech: opponents'
researchedlist is hidden. Theirtech_indexcount is exposed for relative-strength comparison. - Diplomacy: own agreements only. Opponent ↔ opponent agreements hidden unless one party has shared maps with the bound player.
When CP_OMNISCIENT=1: full state, no redaction. Used by the snapshot test
in mc-player-api::tests and for debugging only.
Notifications
One JSON-Lines notification per EventBus signal, in the same order they
fire inside the simulator. The adapter can ignore them entirely (it has
the full view after every act) but they make streaming UIs cheap.
type |
Body schema |
|---|---|
turn_started |
{turn, player} |
turn_ended |
{turn, player} |
phase_changed |
{phase} |
ai_turn_started / ai_turn_completed |
{player[, actions_applied]} |
unit_created / unit_destroyed / unit_moved / unit_promoted |
{unit_id, ...} |
city_founded / city_captured / city_grew / city_starved |
{city_id, ...} |
city_building_completed / city_unit_completed |
{city_id, item} |
combat_resolved |
{attacker_id, defender_id, result: {damage, kills, ...}} |
tech_researched / culture_researched |
{id, player} |
unit_captured / civilian_destroyed / ransom_offered / ransom_accepted / ransom_expired |
per-event payload |
wonder_built |
{wonder_id, player} |
player_eliminated |
{player} |
game_over |
{winner, victory_type} |
turn_timeout |
{player, elapsed_sec} (harness substitutes AI for that turn) |
protocol_error |
{message} |
Notifications are filtered through the same fog-of-war projection as view.
Events about hidden enemy units / hidden tiles are dropped.
Environment variables (harness)
| Var | Default | Meaning |
|---|---|---|
CP_SEED |
42 |
Seed passed to MapGenerator and GameState.initialize_game |
CP_PLAYERS |
2 |
Total player slots |
CP_CLAUDE_SLOT |
0 |
Which slot Claude controls; others run AiTurnBridge |
CP_MAP_SIZE |
duel |
MapGenerator size key |
CP_MAP_TYPE |
continents |
MapGenerator map type |
CP_TURN_LIMIT |
100 |
Auto-end the game at this turn (max) |
CP_TIMEOUT_SEC |
60 |
Per-action timeout; expiry triggers turn_timeout + AI substitution |
CP_OMNISCIENT |
0 |
1 disables fog redaction (debug only) |
CP_LOG_FILE |
unset | If set, harness mirrors all wire I/O to this path |
Client integration: MCP server for Claude Code
The canonical client is Claude Code, talking to a thin MCP server that wraps the harness. No Anthropic API key, no second agent loop — Claude Code is the agent, and the MCP server is just a tool surface.
The server lives at tooling/claude-player-mcp/. Build and wire in via
.mcp.json:
{
"mcpServers": {
"magic-civ": {
"command": "node",
"args": ["./tooling/claude-player-mcp/dist/index.js"]
}
}
}
Three MCP tools become available after reload:
| Tool | Args | Returns |
|---|---|---|
magic_civ_view |
none | Fog-aware PlayerView JSON |
magic_civ_act |
{action: PlayerAction} |
{events, view} |
magic_civ_end_turn |
none | Sugar for act({type:"end_turn"}) |
The MCP server spawns scripts/player-api-server.sh as a child on
first tool invocation and reuses the harness across the session. Any
MCP-stdio client works the same way — claude-player-mcp is just a
thin pump.
UI button → action audit
This audit grounds the PlayerAction enum in the real interactions the
human UI exposes. Verified by grepping each scene's _on_* handlers and
pressed.connect calls.
world_map_hud.gd + top_bar.gd
| Button | Action |
|---|---|
EndTurnButton |
{type: "end_turn"} |
TechButton |
(opens panel — read via view.research) |
DiplomacyButton |
(opens panel — read via view.diplomacy) |
ChronicleButton |
(read-only — exposed via notifications) |
EncyclopediaButton |
(read-only static reference; not in API) |
StatsButton |
(read-only — exposed via view.score) |
BugReportButton |
(out of scope) |
TutorialButton |
(out of scope) |
InfoButton |
(read-only) |
city_screen.gd
| Button / interaction | Action |
|---|---|
add_queue button on a buildable |
{type: "queue_production", city_id, item, [tile]} |
rush_pressed |
{type: "rush_buy", city_id} |
buy_tile_pressed |
{type: "buy_tile", city_id, tile} |
focus_pressed(mode) |
{type: "set_focus", city_id, focus: mode} |
queue_up / queue_down |
{type: "queue_reorder", city_id, from, to} |
queue_remove |
{type: "remove_from_queue", city_id, index} |
merge_btn_pressed |
{type: "merge_buildings", city_id, building_a, building_b, into} |
building_panel_set_rally |
{type: "building_action", kind: "set_rally", ...} |
building_panel_clear_rally |
{type: "building_action", kind: "clear_rally", ...} |
building_panel_garrison_in/out |
{type: "building_action", kind: "garrison_in"/"garrison_out", ...} |
building_panel_repair |
{type: "building_action", kind: "repair", ...} |
building_panel_toggle_active |
{type: "building_action", kind: "toggle_active", ...} |
citizen_tile_clicked |
(UI hint — citizens are auto-assigned by mc-city; no API verb yet) |
tech_tree.gd / culture_tree.gd
| Click | Action |
|---|---|
| Select tech node | {type: "research_tech", tech_id} |
| Select tradition node | {type: "research_tradition", tradition_id} |
diplomacy_panel.gd
| Button | Action |
|---|---|
| Offer Open Borders | {type: "offer_open_borders", to: <player>} |
| Offer Shared Map | {type: "offer_shared_map", to: <player>} |
| Declare War | {type: "declare_war", on: <player>} |
| Offer Peace | {type: "offer_peace", to: <player>} |
| Accept / Reject offer | {type: "accept_open_borders", from} etc. |
Map interactions (no panel)
| Click | Action |
|---|---|
| Left-click own unit | (selection — local UI state only, not in API) |
| Right-click target hex with unit selected | {type: "move", unit_id, to} (or attack if enemy) |
| Right-click ranged target | {type: "ranged_attack", unit_id, target} |
| Movement-mode confirm | {type: "move", ...} (single trip) |
| Patrol pick-mode confirm | {type: "issue_patrol", unit_id, waypoints} |
| Founder on tile + Found City button | {type: "found_city", unit_id} |
| Worker on tile + improvement choice | {type: "build_improvement", unit_id, improvement_id} |
Open follow-ups
- Citizen tile reassignment — the city screen lets the player override auto-assignment but mc-city's API doesn't surface it. Tracked separately.
- Save/load via the API — out of scope for v1; adapter can
shutdownand re-spawn with sameCP_SEED. - Multi-Claude (Claude vs Claude) — needs two harness instances with shared state, or one harness with multiple stdin/stdout pairs. Defer.
References
src/simulator/crates/mc-core/src/action.rs—ActionKind(unit verbs)src/simulator/crates/mc-core/src/city_action.rs—CityActionsrc/simulator/crates/mc-core/src/building_action.rs—BuildingActionKindsrc/simulator/crates/mc-mcts-service/src/framing.rs— line-codec precedentsrc/game/engine/scenes/tests/auto_play.gd— full headless-game harness.project/objectives/p2-67-claude-player-api.md— owning objective