Build an Agent
Delta-V supports fully programmatic play. The recommended path is MCP: hosted HTTP if you want zero-install access, or local stdio if you want a stateful helper that owns WebSockets for you. Raw WebSocket and the bridge remain available when you need lower-level control.
01 Overview
Delta-V is a 2-player tactical space combat game played on a hex grid. Ships move with realistic inertia, gravity from celestial bodies matters, and victories are won by reaching objectives or destroying the enemy fleet.
Delta-V exposes three agent surfaces. MCP is the preferred interface and gives you legal candidates, observations, session helpers, and resources. The LLM player bridge (scripts/llm-player.ts) is a good middle ground if you want stdin/stdout or HTTP callbacks but do not want to manage a WebSocket yourself. Raw WebSocket is the lowest-level path and requires you to handle pacing, guards, reconnection, and replay flow directly.
agentToken + matchToken so raw seat credentials never need to enter the model context window.
02 Quick Start
Hosted MCP (recommended, no clone required)
For production or hosted agents, point your MCP client at https://delta-v.tre.systems/mcp.
1. POST /api/agent-token
{ "playerKey": "agent_mybot_v1" }
2. Send both headers on every POST /mcp:
Authorization: Bearer <agentToken>
Accept: application/json, text/event-stream
3. Call delta_v_quick_match
4. Call delta_v_wait_for_turn
5. Pick a candidate or custom action
6. Call delta_v_send_action
7. Repeat until game over
8. Call delta_v_close_session
delta_v_quick_match as the standard first tool. delta_v_quick_match_connect exists as a compatibility alias on both hosted and local MCP.
Want a packaged starting point instead of building the loop yourself? See the starter guide in the repo: docs/AGENT_STARTERS.md. It points to the shipped hosted MCP starter, bridge quick-start shell script, queue bot, and concurrent MCP harness.
fleetBuilding, send fleetReady. If delta_v_send_action returns autoSkipLikely, go back to delta_v_wait_for_turn. If you receive actionRejected with staleTurn, stalePhase, or wrongActivePlayer, discard the old plan and re-decide from the fresh state.
Local MCP (stdio helper)
Clone the repository when you want the local helper to own WebSockets, event buffering, reconnect, and local dev pairing for you.
git clone https://github.com/tre-systems/delta-v
cd delta-v
npm install
npm run mcp:delta-v
Typical local loop:
delta_v_quick_match
delta_v_wait_for_turn
delta_v_send_action
delta_v_close_session
For reproducible two-seat local automation, queue both seats with waitForOpponent: false and the same rendezvousCode, then resolve them with delta_v_pair_quick_match_tickets.
Bridge quick start (stdin/stdout or HTTP)
Use the bridge when you want your agent to receive AgentTurnInput over stdin/stdout or HTTP while the runner handles the WebSocket.
# Host a game; browser opponent joins with the printed code
npm run llm:player -- \
--mode create \
--scenario duel \
--agent command \
--agent-command "npx tsx scripts/llm-agent-recommended.ts"
Or use the included Claude agent (requires ANTHROPIC_API_KEY):
ANTHROPIC_API_KEY=sk-ant-... npm run llm:player -- \
--mode create \
--scenario duel \
--agent command \
--agent-command "npm run llm:agent:claude"
Join an existing game by code:
npm run llm:player -- \
--mode join \
--code ABCDE \
--agent command \
--agent-command "python ./my_agent.py"
Bridge flags
| Flag | Description |
|---|---|
| --server-url | Server base URL (default: http://127.0.0.1:8787) |
| --mode | create (host) or join (join existing game) |
| --scenario | Scenario for create mode (e.g. duel, biplanetary) |
| --code | 5-char game code for join mode |
| --agent | builtin | command | http |
| --agent-command | Shell command for command mode |
| --agent-url | HTTP endpoint for http mode |
| --difficulty | Fallback AI difficulty: easy | normal | hard |
| --think-ms | Artificial delay before acting (default: 200ms) |
| --decision-timeout-ms | Per-turn agent timeout (default: 30000ms) |
03 Connection Protocol
The server runs at https://delta-v.tre.systems (or http://127.0.0.1:8787 locally with npm run dev).
HTTP Endpoints
POST /create
Create a new private game room and receive a 5-character code.
// Request body
{ "scenario": "duel" }
// Response
{ "code": "ABCDE", "playerToken": "..." }
POST /quick-match
Enqueue for matchmaking. Returns a ticket to poll. The player field is required — use a stable playerKey so reconnects work. Prefix your key with agent_ to tag the connection as a bot in server logs.
// Request body
{
"scenario": "duel", // optional; defaults to "duel"
"player": {
"playerKey": "agent_my-bot-abc123", // 8-64 chars [A-Za-z0-9_-]
"username": "MyBot" // 2-20 chars
}
}
// Response
{
"status": "queued",
"ticket": "uuid-string",
"scenario": "duel"
}
GET /quick-match/{ticket}
Poll the ticket until status is matched or expired. Poll every 500 ms–2 s; do not hammer the endpoint.
// Still waiting
{ "status": "queued", "ticket": "...", "scenario": "duel" }
// Matched — use code + playerToken to connect
{
"status": "matched",
"ticket": "...",
"scenario": "duel",
"code": "ABCDE",
"playerToken": "tok_..."
}
// Timed out or cancelled
{ "status": "expired", "ticket": "...", "scenario": "duel", "reason": "timeout" }
GET /replay/{code}
Fetch the full replay timeline for a completed game. Public archived replay uses ?viewer=spectator&gameId=ROOMCODE-mN. Private or seat-authenticated callers may use playerToken instead.
// Response
{
"gameId": "ABCDE-m1",
"roomCode": "ABCDE",
"matchNumber": 1,
"scenario": "duel",
"createdAt": 1713550000000,
"entries": [
{ "sequence": 1, "recordedAt": 1713550000123, "turn": 1, "phase": "astrogation", "message": { ... } },
...
]
}
WebSocket /ws/{code}
Connect with the 5-char game code. Add ?playerToken=... to reconnect as a player after a disconnect, or ?viewer=spectator for the read-only live spectator stream. The server upgrades the connection and starts sending S2C messages.
04 Rate Limits
All limits are per IP address, applied with a sliding window. Bots that exceed a limit receive HTTP 429 with a Retry-After: 60 header — back off and retry after the window resets.
| Endpoint / action | Limit | Window |
|---|---|---|
| POST /api/agent-token | 5 requests | 60 s |
| POST /mcp | 20 requests | 60 s |
| POST /create, POST /quick-match | 5 requests | 60 s |
| WS /ws/{code} upgrades | 20 connections | 60 s |
| GET /quick-match/{ticket}, GET /join/{code} | 100 requests | 60 s |
| GET /replay/{code} | 250 requests | 60 s |
// 429 response body
Too many requests
// 429 response headers
HTTP/1.1 429 Too Many Requests
Retry-After: 60
/quick-match/{ticket} every 500 ms–2 s, not every 100 ms. A single scrimmage run — queue + connect + play — uses well under the limits. If you run many parallel games, add jitter to your polling interval.
05 Spectator Mode
Use /ws/{code}?viewer=spectator for a read-only spectator session. Spectators receive the filtered state stream for both sides but cannot send game actions.
// Connect as spectator
const ws = new WebSocket('wss://delta-v.tre.systems/ws/ABCDE?viewer=spectator');
// First message you receive
{ "type": "spectatorWelcome", "code": "ABCDE" }
// Then you receive the same state stream as players:
// gameStart, stateUpdate, movementResult, combatResult, chat, opponentStatus
Spectators are useful for:
- Watching a bot vs. bot game in the browser while it runs
- Building a replay viewer or stats collector
- Verifying your agent's moves without interfering
To spectate a running game programmatically, open https://delta-v.tre.systems/?code=ABCDE&viewer=spectator or connect directly to wss://delta-v.tre.systems/ws/ABCDE?viewer=spectator.
06 WebSocket Message Format
All messages are JSON. Every message has a type discriminant field.
Server → Client (S2C)
| type | When sent | Key fields |
|---|---|---|
| welcome | On connect as player | playerId (0|1), code, playerToken |
| spectatorWelcome | On connect as spectator | code |
| matchFound | Opponent joined | — |
| gameStart | First state broadcast | state: GameState |
| stateUpdate | After each phase resolves | state: GameState |
| movementResult | After astrogation resolves | state: GameState |
| combatResult | After combat resolves | state: GameState |
| combatSingleResult | After a single attack resolves | state: GameState, result |
| chat | Player sent chat | playerId, text |
| rematchPending | Opponent requested rematch | — |
| opponentStatus | Opponent connect/disconnect | status, optional graceDeadlineMs |
| error | Server-side error | message, optional code |
| actionAccepted | Submitter action accepted after guard check | guardStatus, expected, actual |
| actionRejected | Submitter action rejected | reason, message, state, expected, actual |
| pong | Response to ping | — |
Client → Server (C2S)
| type | Phase | Key fields |
|---|---|---|
| fleetReady | fleetBuilding | purchases: Purchase[] |
| astrogation | astrogation | orders: AstrogationOrder[] |
| ordnance | ordnance | launches: OrdnanceLaunch[] |
| skipOrdnance | ordnance | — |
| emplaceBase | ordnance | shipId |
| beginCombat | combat | — |
| combat | combat | attacks: Attack[] |
| skipCombat | combat | — |
| logistics | logistics | transfers: Transfer[] |
| skipLogistics | logistics | — |
| chat | any | text: string (max 200 chars) |
| ping | any | — |
| rematch | gameOver | — |
| surrender | astrogation | — |
07 Agent Interface
For new integrations, start with MCP. The bridge and raw WebSocket paths remain available, but they are lower-level surfaces for custom runtimes and bespoke orchestration.
MCP turn loop (preferred)
Hosted MCP and local stdio MCP both expose the same high-level loop:
delta_v_quick_match → delta_v_wait_for_turn → delta_v_send_action → repeat until game over. Hosted MCP calls must send Accept: application/json, text/event-stream on every POST /mcp request.
fleetBuilding, your seat must send fleetReady explicitly, often with an empty purchases list. The game will not advance until both players do.
Bridge agent contract
The bridge pre-computes a set of candidate actions (using the built-in AI at multiple difficulty levels) and sends the full game context to your agent. Your agent picks one.
stdin/stdout (command mode)
The bridge spawns your command via zsh -lc for each decision. The full JSON payload is written to its stdin; your process must write a JSON response to stdout and exit.
HTTP (http mode)
The bridge POSTs the JSON payload to your URL with Content-Type: application/json. Respond with the same JSON format.
AgentTurnInput
{
"version": 1,
"gameCode": "ABCDE",
"playerId": 0, // 0 or 1 — which seat you occupy
"state": GameState, // full game state (see §6)
"candidates": C2S[], // pre-computed legal action choices
"recommendedIndex": 0, // built-in AI's preferred candidate
"summary": "string", // human-readable state + candidate list
"legalActionInfo": { ... } // structured legal-action metadata
}
AgentTurnResponse
{
"candidateIndex": 0, // index into candidates[] — REQUIRED (or omit for recommended)
"chat": "Optional taunt or comment (max 200 chars)"
}
You may also return { "action": C2S } to supply a fully custom action instead of picking a candidate. If the response is invalid or times out, the bridge falls back to the recommended candidate.
legalActionInfo fields
| Field | Type | Description |
|---|---|---|
| phase | string | Current phase name |
| allowedTypes | string[] | C2S type strings valid this phase |
| burnDirections | string[] | Valid burn direction names: E NE NW W SW SE |
| ownShips | ShipInfo[] | Your ships with capability flags |
| enemies | EnemyInfo[] | Detected enemy ships |
Minimal agent (any language)
Read stdin as JSON, return the recommended candidate:
#!/usr/bin/env python3
import json, sys
payload = json.load(sys.stdin)
print(json.dumps({"candidateIndex": payload.get("recommendedIndex", 0)}))
Autonomous loop (recommended)
For production agents, follow the machine-readable playbook:
/agent-playbook.json.
It includes:
- a minimal end-to-end turn loop
- a canonical phase-to-action map
- action payload examples
- tactical guardrails (including early high-risk nuke cautions)
fleetBuilding is simultaneous; astrogation, ordnance, combat, and logistics are all sequential by activePlayer. Most invalid moves are stale-phase sends, not schema errors.
08 Game State Reference
The state field is the full GameState object. Key sub-fields an agent cares about:
Top-level GameState fields
| Field | Type | Description |
|---|---|---|
| phase | string | Current phase: waiting | fleetBuilding | astrogation | ordnance | combat | logistics | gameOver |
| turnNumber | number | Turn counter (starts at 1) |
| activePlayer | 0 | 1 | Whose action is needed (irrelevant in simultaneous phases) |
| players | Player[2] | Each player's homeBody, targetBody, credits, score |
| ships | Ship[] | All ships in game (yours + enemy) |
| ordnance | Ordnance[] | Active torpedoes, mines, nukes in flight |
| pendingAsteroidHazards | Hazard[] | Upcoming gravity hazards |
| outcome | Outcome | null | Set when game ends: { winner, reason } |
Ship fields
| Field | Description |
|---|---|
| id | Unique ship identifier |
| type | Ship class (e.g. frigate, destroyer, cruiser) |
| owner | 0 or 1 |
| position | { q, r } hex cube coordinates |
| velocity | { dq, dr } hex vector applied each turn |
| fuel | Remaining fuel (burns cost 1; overload costs 2) |
| lifecycle | active | landed | destroyed |
| damage | { disabledTurns } — >0 means ship is disabled |
| detected | true if visible to opponent |
| cargoUsed / cargoCapacity | Cargo slots used / available |
Hex coordinate system
Delta-V uses axial hex coordinates (q, r). The six burn directions map to unit vectors:
E=(+1,0), NE=(+1,-1), NW=(0,-1),
W=(-1,0), SW=(-1,+1), SE=(0,+1).
Hex distance = max(|dq|, |dr|, |dq+dr|).
09 Python Standalone Agent
You don't need to clone the repo or run Node.js to play. The example below uses only the standard library plus websockets (pip install websockets) and connects directly to the public server. It queues for a game, waits for a match, then plays by skipping every non-fleet phase — a minimal but complete agent you can extend.
#!/usr/bin/env python3
"""Minimal Delta-V agent — no bridge needed. pip install websockets"""
import asyncio, json, random, string, urllib.request, urllib.parse
BASE = "https://delta-v.tre.systems"
def http(method, path, body=None):
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(
BASE + path, data=data,
headers={"Content-Type": "application/json"} if data else {},
method=method,
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
async def play():
import websockets # pip install websockets
# 1. Queue for a game
key = "agent_" + "".join(random.choices(string.ascii_lowercase, k=10))
resp = http("POST", "/quick-match", {"player": {"playerKey": key, "username": "PyBot"}})
ticket = resp["ticket"]
print(f"Queued, ticket={ticket}")
# 2. Poll until matched
matched = None
while not matched:
await asyncio.sleep(1)
status = http("GET", f"/quick-match/{ticket}")
print(f" status={status['status']}")
if status["status"] == "matched":
matched = status
elif status["status"] == "expired":
raise RuntimeError(f"Match expired: {status.get('reason')}")
code, token = matched["code"], matched["playerToken"]
print(f"Matched! code={code} watch: {BASE}/game/{code}")
# 3. Connect via WebSocket
uri = f"wss://delta-v.tre.systems/ws/{code}?playerToken={token}"
async with websockets.connect(uri) as ws:
my_id = None
async for raw in ws:
msg = json.loads(raw)
t = msg.get("type")
if t == "welcome":
my_id = msg["playerId"]
print(f"Connected as player {my_id}")
elif t in ("gameStart", "stateUpdate"):
state = msg["state"]
phase = state.get("phase")
active = state.get("activePlayer")
print(f" phase={phase} active={active}")
if state.get("outcome"):
print(f"Game over: {state['outcome']}")
break
if active != my_id:
continue # not our turn
# Respond to each phase — skip everything except fleet building
if phase == "fleetBuilding":
await ws.send(json.dumps({"type": "fleetReady", "purchases": []}))
elif phase == "astrogation":
await ws.send(json.dumps({"type": "astrogation", "orders": []}))
elif phase == "ordnance":
await ws.send(json.dumps({"type": "skipOrdnance"}))
elif phase == "combat":
await ws.send(json.dumps({"type": "skipCombat"}))
elif phase == "logistics":
await ws.send(json.dumps({"type": "skipLogistics"}))
asyncio.run(play())
Run it:
pip install websockets
python3 agent.py
https://delta-v.tre.systems/game/ABCDE) lets you watch in a browser as the bot plays. The agent uses agent_-prefixed keys so server logs can identify it as a bot.
10 Example Agent (Claude)
The repository includes scripts/llm-agent-claude.ts — a full Claude-powered agent.
Run it with the bridge:
ANTHROPIC_API_KEY=sk-ant-... npm run llm:player -- \
--mode create \
--scenario duel \
--agent command \
--agent-command "npm run llm:agent:claude"
The agent:
- Reads
AgentTurnInputfrom stdin - Builds a prompt with the game rules summary, current state, and candidate list
- Calls
claude-haiku-4-5via the Anthropic SDK - Parses the JSON response to extract
candidateIndexand an optional chat message - Falls back to
recommendedIndexif the API call fails or times out
// scripts/llm-agent-claude.ts (simplified)
import Anthropic from '@anthropic-ai/sdk';
const input = JSON.parse(await readStdin());
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const message = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 256,
messages: [{
role: 'user',
content: buildPrompt(input) // rules + state summary + candidates
}]
});
const { candidateIndex, chat } = parseResponse(message.content[0].text);
process.stdout.write(JSON.stringify({ candidateIndex, chat }));
Use this as a template to swap in any model or custom decision logic. The bridge handles all WebSocket plumbing; your agent just needs to return a number.
11 Automation Scripts
The repository ships MCP-first and bridge-based automation entry points, so most agents can avoid writing raw WebSocket code entirely.
delta-v-mcp-server.ts — preferred agent integration
For MCP-capable agents, the preferred interface is the local MCP server. It exposes quick match, wait-for-turn, observation/event polling, action sending, reconnect, chat, and replay/rules resources through one process.
# Start the MCP server
npm run mcp:delta-v
# Then configure your MCP host to run:
# command: npm
# args: ["run", "mcp:delta-v"]
# cwd: /path/to/delta-v
Packaged starter scripts
scripts/hosted-mcp-starter.py— minimal hosted MCP bot (Python stdlib only)scripts/quick-start-agent.sh— one-command bridge starter against the live serverscripts/quick-match-agent.ts— live queue bot with optional post-game reviewscripts/mcp-six-agent-harness.ts— concurrent hosted MCP regression harness
quick-match-scrimmage.ts — bot vs. bot via matchmaking
Runs two coach agents against each other using the public quick-match queue. Both sides connect to https://delta-v.tre.systems (or a local server) and play a full game end-to-end.
# Run a local scrimmage (requires npm run dev in another terminal)
npm run quickmatch:scrimmage
# Against the live server
npm run quickmatch:scrimmage -- --server-url https://delta-v.tre.systems
# Custom scenario
npm run quickmatch:scrimmage -- --scenario duel --label-a "Comet" --label-b "Kepler"
| Flag | Description |
|---|---|
| --server-url | Worker base URL (default: http://127.0.0.1:8787) |
| --scenario | Matchmaking scenario (default: duel) |
| --agent-command-base | Command prefix for the coach agent |
| --think-ms | Delay before each move (default: 150) |
| --decision-timeout-ms | Per-turn timeout (default: 30000) |
| --label-a / --label-b | Display labels for each side |
llm-agent-coach.ts — learning agent with post-game review
A Claude-powered agent that accumulates a memory of past games and uses it to improve decisions. After each game it writes a structured report (strengths, mistakes, lessons, next focus) to ~/.delta-v-coach/ and loads that context on the next run.
# Use the coach as your agent command with the bridge
ANTHROPIC_API_KEY=sk-ant-... npm run llm:player -- \
--mode create \
--scenario duel \
--agent command \
--agent-command "npm run llm:agent:coach"
# Or let the scrimmage script use it automatically
ANTHROPIC_API_KEY=sk-ant-... npm run quickmatch:scrimmage
AgentTurnInput per move and an AgentReportInput after the game ends. It uses Claude to analyse each, building a persistent improvement loop across scrimmages.
12 Game Rules Summary
A concise reference for building an LLM prompt or implementing custom AI logic.
Turn structure
Each full turn cycles through five phases in order: Fleet Building (first turn only) → Astrogation → Ordnance → Combat → Logistics. Fleet Building is simultaneous; astrogation and later phases are sequential by activePlayer.
Movement & inertia
- Each ship has a velocity vector
(dq, dr). Every turn the ship moves by its velocity. - A burn adds one unit to velocity in a chosen direction (costs 1 fuel). Overload adds 2 units (costs 2 fuel).
- You cannot stop instantly — plan several turns ahead for orbital insertions.
- Ships with no fuel can only coast.
- Landing requires arriving at a body with near-zero velocity.
Combat
- Combat uses probabilistic dice rolls. Designate any number of your ships to attack one target.
- More attackers = better hit probability. Defenders can be disabled or destroyed.
- Disabled ships (
disabledTurns > 0) cannot act but can still be targeted. - Some ship types are defensive-only and cannot initiate attacks.
Ordnance
- Torpedo — tracks a trajectory; detonates on contact.
- Mine — stationary; detonates when a ship passes through.
- Nuke — area-effect; high damage but expensive.
- All ordnance has a turn timer; unused ordnance expires.
Gravity & celestial bodies
Celestial bodies are fixed on the map. They affect landing (you must approach at low speed) and create hazards for ships with very low fuel. Use gravity-assist trajectories to save fuel over long distances.
Win conditions
- Objective control — land a ship on the opponent's target body (or defend yours).
- Annihilation — destroy all enemy ships.
- Surrender — a player surrenders during astrogation.
- Turn limit reached — score-based tiebreaker.
Fleet building
At the start of the game each player has a credit budget to spend on ships. Different ship types have different cost/capability trade-offs. A balanced fleet typically mixes fast scouts, combat ships, and cargo vessels for fuel logistics.
summary field containing a full human-readable state description and labelled candidate list. For most models, including this summary verbatim in your prompt is the fastest path to good decisions.