Programmatic Play

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.

Recommended
Start with MCP unless you have a concrete reason not to. Hosted MCP uses 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
Canonical tool name
Use 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.

Decision checklist
If you are still in 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

FlagDescription
--server-urlServer base URL (default: http://127.0.0.1:8787)
--modecreate (host) or join (join existing game)
--scenarioScenario for create mode (e.g. duel, biplanetary)
--code5-char game code for join mode
--agentbuiltin | command | http
--agent-commandShell command for command mode
--agent-urlHTTP endpoint for http mode
--difficultyFallback AI difficulty: easy | normal | hard
--think-msArtificial delay before acting (default: 200ms)
--decision-timeout-msPer-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
POST
/quick-match
GET
/quick-match/{ticket}
GET
/join/{code}
GET
/replay/{code}
WS
/ws/{code}[?playerToken=...]

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 / actionLimitWindow
POST /api/agent-token5 requests60 s
POST /mcp20 requests60 s
POST /create, POST /quick-match5 requests60 s
WS /ws/{code} upgrades20 connections60 s
GET /quick-match/{ticket}, GET /join/{code}100 requests60 s
GET /replay/{code}250 requests60 s
// 429 response body
Too many requests

// 429 response headers
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Bot advice
Poll /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:

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)

typeWhen sentKey fields
welcomeOn connect as playerplayerId (0|1), code, playerToken
spectatorWelcomeOn connect as spectatorcode
matchFoundOpponent joined
gameStartFirst state broadcaststate: GameState
stateUpdateAfter each phase resolvesstate: GameState
movementResultAfter astrogation resolvesstate: GameState
combatResultAfter combat resolvesstate: GameState
combatSingleResultAfter a single attack resolvesstate: GameState, result
chatPlayer sent chatplayerId, text
rematchPendingOpponent requested rematch
opponentStatusOpponent connect/disconnectstatus, optional graceDeadlineMs
errorServer-side errormessage, optional code
actionAcceptedSubmitter action accepted after guard checkguardStatus, expected, actual
actionRejectedSubmitter action rejectedreason, message, state, expected, actual
pongResponse to ping

Client → Server (C2S)

typePhaseKey fields
fleetReadyfleetBuildingpurchases: Purchase[]
astrogationastrogationorders: AstrogationOrder[]
ordnanceordnancelaunches: OrdnanceLaunch[]
skipOrdnanceordnance
emplaceBaseordnanceshipId
beginCombatcombat
combatcombatattacks: Attack[]
skipCombatcombat
logisticslogisticstransfers: Transfer[]
skipLogisticslogistics
chatanytext: string (max 200 chars)
pingany
rematchgameOver
surrenderastrogation

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_matchdelta_v_wait_for_turndelta_v_send_action → repeat until game over. Hosted MCP calls must send Accept: application/json, text/event-stream on every POST /mcp request.

Fleet building
If the current observation still reports 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

FieldTypeDescription
phasestringCurrent phase name
allowedTypesstring[]C2S type strings valid this phase
burnDirectionsstring[]Valid burn direction names: E NE NW W SW SE
ownShipsShipInfo[]Your ships with capability flags
enemiesEnemyInfo[]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:

Phase discipline
Before sending any action, re-check the latest turn, phase, and active player. Only 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

FieldTypeDescription
phasestringCurrent phase: waiting | fleetBuilding | astrogation | ordnance | combat | logistics | gameOver
turnNumbernumberTurn counter (starts at 1)
activePlayer0 | 1Whose action is needed (irrelevant in simultaneous phases)
playersPlayer[2]Each player's homeBody, targetBody, credits, score
shipsShip[]All ships in game (yours + enemy)
ordnanceOrdnance[]Active torpedoes, mines, nukes in flight
pendingAsteroidHazardsHazard[]Upcoming gravity hazards
outcomeOutcome | nullSet when game ends: { winner, reason }

Ship fields

FieldDescription
idUnique ship identifier
typeShip class (e.g. frigate, destroyer, cruiser)
owner0 or 1
position{ q, r } hex cube coordinates
velocity{ dq, dr } hex vector applied each turn
fuelRemaining fuel (burns cost 1; overload costs 2)
lifecycleactive | landed | destroyed
damage{ disabledTurns } — >0 means ship is disabled
detectedtrue if visible to opponent
cargoUsed / cargoCapacityCargo 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
Tip
The game URL printed by the script (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:

  1. Reads AgentTurnInput from stdin
  2. Builds a prompt with the game rules summary, current state, and candidate list
  3. Calls claude-haiku-4-5 via the Anthropic SDK
  4. Parses the JSON response to extract candidateIndex and an optional chat message
  5. Falls back to recommendedIndex if 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

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"
FlagDescription
--server-urlWorker base URL (default: http://127.0.0.1:8787)
--scenarioMatchmaking scenario (default: duel)
--agent-command-baseCommand prefix for the coach agent
--think-msDelay before each move (default: 150)
--decision-timeout-msPer-turn timeout (default: 30000)
--label-a / --label-bDisplay 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
How it works
The coach agent receives an 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) → AstrogationOrdnanceCombatLogistics. Fleet Building is simultaneous; astrogation and later phases are sequential by activePlayer.

Movement & inertia

Combat

Ordnance

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

  1. Objective control — land a ship on the opponent's target body (or defend yours).
  2. Annihilation — destroy all enemy ships.
  3. Surrender — a player surrenders during astrogation.
  4. 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.

LLM Prompt Tip
The bridge already passes a 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.