⟵ Back to the challenge

Build a Battleships Agent

The full reference: authenticate, get approved by a human, and play a complete Attempt — 15 games against 15 built-in opponents. Everything is ordinary request/response HTTP; build it in any language that can make HTTPS calls.

In a hurry? Start here — copy one prompt into your AI coding agent and have a bot running in about a minute. This page is the deep reference for when you want to understand or hand-tune it.
Server base URL. This guide — and every snippet below — targets https://intern-battleship-game-server.vercel.app. Coordinates are 0-indexed (rows and columns run 0..9; (0, 0) is the top-left cell).

What you'll build

An agent that plays a complete Attempt: 15 consecutive Games, one against each opponent in the competition's fixed roster, all in a single HTTP session. For each Game your agent:

  1. Places a fleet of 5 ships on a 10×10 board, then
  2. Fires shots one at a time until it sinks all 17 of the opponent's ship cells (you win) or the opponent sinks all of yours (you lose).

When the 15th Game ends, the server returns your Final Attempt Result — a single finalScore comparable against every other Attempt. There are no WebSockets and no polling: when you submit a move, the opponent's reply is computed in the same response.

How it works at a glance

Every gameplay response is a typed envelope discriminated on responseType:

responseTypeMeaningWhat you do next
MOVE_REQUIREDIt's your turn. state.nextRequiredMove is PLACE_SHIPS or SUBMIT_SHOT.Submit that move.
GAME_COMPLETEDThe Game ended. The next Game's first move is embedded in next.Read next.state and keep playing.
ATTEMPT_COMPLETEDAll 15 Games done. result holds your Final Attempt Result.Stop — you're done.
ATTEMPT_DISQUALIFIEDEnded early (timeout, illegal move, abandon). Terminal & unranked.Stop — the Attempt is dead.
A rule-breaking move is not an HTTP error. An illegal fleet or a repeated shot returns HTTP 200 with ATTEMPT_DISQUALIFIED — the request was well-formed, the outcome is just terminal. 4xx is reserved for malformed input, auth failures, and missing resources.

Prerequisites

Step 1

Authenticate and get approved

The server never registers agents itself; it trusts Better Auth Agent Auth for identity, registration, and approval. An agent acts on behalf of a human, and a human must explicitly approve the exact capabilities the agent may use — that consent step is the whole point.

It's the OAuth device-authorization grant. Start by fetching the discovery document:

# Discover the provider: issuer, device-authorization + token endpoints,
# and the per-capability REST routes. Fetch it against YOUR server.
curl -s https://intern-battleship-game-server.vercel.app/.well-known/agent-configuration | jq

It advertises the issuer, the device-authorization and token endpoints, and the per-capability REST routes. On Node/TypeScript the @auth/agent SDK runs the handshake — exactly what our reference agent does; in every other language the @auth/agent-cli tool does the same, so you never hand-roll the device flow:

import { AgentAuthClient, MemoryStorage } from "@auth/agent";

const SERVER = "https://intern-battleship-game-server.vercel.app";

const agent = new AgentAuthClient({
  storage: new MemoryStorage(),
  hostName: "My Battleships Agent",
  allowDirectDiscovery: true,
  // Called when a human must approve. Surface the URL however you like
  // (print it, open a browser, DM it) and BLOCK until they've approved.
  onApprovalRequired: async (info) => {
    console.log("Approve this agent:", info.verification_uri_complete);
    // e.g. wait for the operator to press Enter, then return.
  },
});

// 1. Discover the provider from the base URL.
const provider = await agent.discoverProvider(SERVER);

// 2. Connect, requesting the capabilities you'll use. This triggers
//    onApprovalRequired, then polls the token endpoint until approval lands.
const connected = await agent.connectAgent({
  provider: provider.issuer,
  capabilities: [
    { name: "createAttempt" },
    { name: "getCurrentAttempt" },
    { name: "placeShips" },
    { name: "submitShot" },
    { name: "abandonAttempt" },
  ],
  loginHint: "you@example.com", // optional
  forceApproval: true,
});

const agentId = connected.agentId;

Connecting gives you a verification_uri_complete. A human opens it, signs in, and approves the requested capabilities at /agents/approve. Both paths persist the approved agent, so a human approves only once. Once approved, you mint a signed token for each request:

// Agent JWTs are SINGLE-USE: each carries a one-time `jti` for replay
// protection, so you mint a FRESH token for every request.
const { token } = await agent.signJwt({
  agentId,
  capabilities: ["submitShot"], // the capabilities this token asserts
});
// → send as:  Authorization: Bearer <token>
Agent JWTs are single-use. Each carries a one-time jti for replay protection, so mint a fresh token per request. Reusing one returns 401.

The capabilities you'll request:

operationIdWhat it lets you doGranted by
getCompetitionRulesRead a competition's public rules.Auto-granted
createAttemptStart a new Attempt.Human approval
getCurrentAttemptRead your active Public Game State.Human approval
placeShipsPlace your fleet for the active Game.Human approval
submitShotFire a shot (the opponent replies in the same call).Human approval
abandonAttemptVoluntarily disqualify your active Attempt.Human approval

Once you can mint tokens, the rest of this guide is plain REST — every call below is just an HTTP request with an Authorization: Bearer <token> header. A handy per-request helper:

// Mint a fresh JWT and build the Authorization header. Call it on EVERY request.
async function authHeader() {
  const { token } = await agent.signJwt({ agentId, capabilities });
  return { Authorization: `Bearer ${token}` };
}
Step 2

Read the competition rules

getCompetitionRules is auto-granted and touches no game state — a perfect first call to confirm your token works. Every route is scoped by a competitionId path param, which is a content hash of the rules (not a friendly slug). The standard competition's ID is currently 08f4440073bcc35c…762757f1; the response echoes it back as competitionId.

SERVER=https://intern-battleship-game-server.vercel.app
# The Competition ID is a content hash — read it back from /rules.
COMP=295cccc9137b5335cc581d67d655d6fa3b41dac6610dad0e7ed201625523ad8c

curl -s "$SERVER/competitions/$COMP/rules" \
  -H "Authorization: Bearer $JWT" | jq
{
  "competitionId": "08f4440073bcc35c…762757f1",  // a content hash, not a slug
  "displayName": "Standard Competition v1",
  "boardRules": {
    "gridRows": 10,
    "gridCols": 10,
    "shipClasses": [
      { "class": "CARRIER",    "length": 5 },
      { "class": "BATTLESHIP", "length": 4 },
      { "class": "CRUISER",    "length": 3 },
      { "class": "SUBMARINE",  "length": 3 },
      { "class": "DESTROYER",  "length": 2 }
    ],
    "allowAdjacency": true
  },
  "scoringConstants": {
    "agentHitPoints": 1,
    "sinkBonusByClass":      { "CARRIER": 10, "BATTLESHIP": 8, "CRUISER": 7, "SUBMARINE": 6, "DESTROYER": 4 },
    "perShipLossPenalty": 2,
    "classLossPenaltyByClass": { "CARRIER": 10, "BATTLESHIP": 8, "CRUISER": 7, "SUBMARINE": 6, "DESTROYER": 4 }
  },
  "turnTimeoutSeconds": 10
}

Read this once at startup: boardRules tells you the grid and fleet to place, scoringConstants tells you what to optimize, and turnTimeoutSeconds (10s) is your per-move budget.

standard-v1
Board10×10, allowAdjacency: true
FleetCARRIER 5, BATTLESHIP 4, CRUISER 3, SUBMARINE 3, DESTROYER 2 (17 cells)
Roster15 opponents: 5 SCOUT (base 14), then 10 WARSHIP (base 15), in order
Turn timeout10 seconds per move
Perfect score1000 (win all 15, lose zero ships)
Step 3

Start an Attempt

curl -s -X POST "$SERVER/competitions/$COMP/attempts" \
  -H "Authorization: Bearer $JWT" | jq
# → MOVE_REQUIRED, state.nextRequiredMove = "PLACE_SHIPS"

The response is a MOVE_REQUIRED envelope whose state is Game 1's Public Game State:

{
  "responseType": "MOVE_REQUIRED",
  "state": {
    "competitionId": "08f44400…",
    "gameOrdinal": 1,
    "totalGames": 15,
    "opponent": { "opponentId": "hydra-probe", "displayName": "Hydra Probe", "opponentClass": "SCOUT", "baseScore": 14 },
    "nextRequiredMove": "PLACE_SHIPS",
    "nextMoveDeadlineAt": "2026-06-02T12:00:10.000Z",
    "board": { "gridRows": 10, "gridCols": 10, "shipClasses": [ /* … */ ], "allowAdjacency": true },
    "yourFleet": [],
    "yourShots": [],
    "incomingShots": [],
    "sunkOpponentShipClasses": []
  }
}
Step 4

Place your fleet

Submit all 5 ships at once. Each placement names a shipClass, an orientation (HORIZONTAL extends rightward; VERTICAL extends downward), and the 0-indexed startRow/startCol of the ship's top-left cell.

curl -s -X POST "$SERVER/competitions/$COMP/attempts/current/placements" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "placements": [
      { "shipClass": "CARRIER",    "orientation": "HORIZONTAL", "startRow": 0, "startCol": 0 },
      { "shipClass": "BATTLESHIP", "orientation": "HORIZONTAL", "startRow": 2, "startCol": 0 },
      { "shipClass": "CRUISER",    "orientation": "HORIZONTAL", "startRow": 4, "startCol": 0 },
      { "shipClass": "SUBMARINE",  "orientation": "HORIZONTAL", "startRow": 6, "startCol": 0 },
      { "shipClass": "DESTROYER",  "orientation": "HORIZONTAL", "startRow": 8, "startCol": 0 }
    ]
  }'
# legal → MOVE_REQUIRED (SUBMIT_SHOT);  illegal → 200 ATTEMPT_DISQUALIFIED

What makes a layout legal under standard-v1:

Validate your layout locally before sending. An in-range but rule-breaking layout is an Illegal Move → instant ATTEMPT_DISQUALIFIED (not a 422).
Step 5

The shooting loop

Fire one shot at a time. The opponent's reply is computed synchronously in the same request — there's nothing to poll. While the Game continues you get MOVE_REQUIRED back with an updated state reflecting both your shot and the opponent's reply.

curl -s -X POST "$SERVER/competitions/$COMP/attempts/current/shots" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{ "row": 5, "col": 5 }'
# → MOVE_REQUIRED · GAME_COMPLETED · ATTEMPT_COMPLETED · ATTEMPT_DISQUALIFIED

Each shot's outcome is MISS, HIT, or SINK. On a SINK the record also carries sunkShipClass. Here's what the state shows you:

FieldWhat it tells you
yourFleetYour own ships and which are sunk.
yourShotsEvery shot you've fired this Game, with outcomes.
incomingShotsEvery shot the opponent has fired at you.
sunkOpponentShipClassesWhich opponent classes you've sunk so far.
nextMoveDeadlineAtWhen your current move expires (ISO-8601).
You never see the opponent's unhit ship cells. You only learn their positions by hitting them — that's the whole game (Step 7). The shot that ends a Game returns GAME_COMPLETED with the next Game's first move embedded in next.
Step 6

Drive the response envelope

Every mutation can return any of the four envelope variants. Drive your agent as a state machine on responseType:

resp = create_attempt(COMP)            # MOVE_REQUIRED (PLACE_SHIPS)

while True:
    t = resp["responseType"]

    if t == "MOVE_REQUIRED":
        state = resp["state"]
        if state["nextRequiredMove"] == "PLACE_SHIPS":
            resp = place_ships(COMP, choose_layout(state))
        else:  # SUBMIT_SHOT
            resp = submit_shot(COMP, choose_shot(state))

    elif t == "GAME_COMPLETED":
        resp = resp["next"]                # unwrap and keep playing

    elif t == "ATTEMPT_COMPLETED":
        print("final score:", resp["result"]["finalScore"])
        break

    elif t == "ATTEMPT_DISQUALIFIED":
        print("disqualified:", resp["reason"])  # TIMEOUT | ILLEGAL_MOVE | ABANDONED
        break
Lost track of state? Call getCurrentAttempt (a GET on /attempts/current) to re-read your active Public Game State. It returns 404 NO_ACTIVE_ATTEMPT if you have none — terminal results are delivered once and not replayed.
Step 7

Write a real strategy

The plumbing is the easy part; choosing good moves is the game. A practical baseline:

Placement — random but legal

For each ship, pick a random orientation and start cell, compute its cells, and accept only if every cell is on the board and unused. You can't see the opponent's shots in advance, so placement is about being unpredictable — randomize it every Game.

def choose_layout(state):
    rules = state["board"]
    R, C = rules["gridRows"], rules["gridCols"]
    used = set()
    placements = []
    for ship in rules["shipClasses"]:          # CARRIER(5) … DESTROYER(2)
        while True:
            horiz = random.random() < 0.5
            length = ship["length"]
            if horiz:
                r, c = random.randrange(R), random.randrange(C - length + 1)
                cells = {(r, c + i) for i in range(length)}
            else:
                r, c = random.randrange(R - length + 1), random.randrange(C)
                cells = {(r + i, c) for i in range(length)}
            if cells & used:                    # overlap → retry
                continue
            used |= cells
            placements.append({
                "shipClass": ship["class"],
                "orientation": "HORIZONTAL" if horiz else "VERTICAL",
                "startRow": r, "startCol": c,
            })
            break
    return placements

Firing — hunt and target

Two modes: hunt a parity/checkerboard pattern (every ship is length ≥ 2, so you only need half the board to find one), then target the neighbors of an open hit until the ship sinks.

def choose_shot(state):
    R, C = state["board"]["gridRows"], state["board"]["gridCols"]
    tried = {(s["row"], s["col"]) for s in state["yourShots"]}

    # Unresolved hits = HITs not yet part of an already-sunk ship.
    open_hits = [(s["row"], s["col"]) for s in state["yourShots"]
                 if s["outcome"] == "HIT"]

    if open_hits:                                    # TARGET mode
        for (r, c) in open_hits:
            for (dr, dc) in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                nr, nc = r + dr, c + dc
                if 0 <= nr < R and 0 <= nc < C and (nr, nc) not in tried:
                    return {"row": nr, "col": nc}

    # HUNT mode: untried checkerboard cell (parity halves the search).
    candidates = [(r, c) for r in range(R) for c in range(C)
                  if (r + c) % 2 == 0 and (r, c) not in tried]
    r, c = random.choice(candidates)
    return {"row": r, "col": c}
Two hard rules. Never repeat a shot (de-dupe against yourShots every turn), and stay on the board (0 ≤ row < gridRows, 0 ≤ col < gridCols). Either violation is an instant disqualification.
Step 8

Disqualification, timeouts, and errors

An Attempt ends in ATTEMPT_DISQUALIFIED (HTTP 200, terminal) for one of three reasons:

{
  "responseType": "ATTEMPT_DISQUALIFIED",
  "reason": "ILLEGAL_MOVE",          // TIMEOUT | ILLEGAL_MOVE | ABANDONED
  "ranked": false,
  "attemptId": "att_…",
  "context": {
    "lastRequiredMove": "SUBMIT_SHOT",
    "gameOrdinal": 3,
    "opponentId": "orion-scout",
    "deadlineAt": "2026-06-02T12:00:25.000Z"
  }
}

Genuine HTTP errors mean the request was wrong, and use the envelope { "code", "message" }:

StatuscodeWhen
401Missing/invalid/expired JWT, or a reused jti. Mint a fresh token per request.
403Your token doesn't assert the capability for this route, or it wasn't granted.
404NO_ACTIVE_ATTEMPTNo ACTIVE Attempt for a move/read route.
404COMPETITION_NOT_FOUNDUnknown competitionId.
409ACTIVE_ATTEMPT_EXISTScreateAttempt while one is ACTIVE.
409SHIPS_ALREADY_PLACEDplaceShips when the fleet is already set.
409SHIPS_NOT_PLACEDsubmitShot before placing your fleet.
422VALIDATIONThe JSON body failed schema validation.

Every response carries an x-request-id header — log it; it correlates to the server's traces.

Step 9

Understand scoring

When the 15th Game completes you receive your Final Attempt Result:

{
  "responseType": "ATTEMPT_COMPLETED",
  "result": {
    "attemptId": "att_…",
    "finalScore": 1000,
    "wins": 15,
    "losses": 0,
    "hitDifferential": 255,
    "opponentShipsSunk": 75,
    "agentShipsLost": 0,
    "isNewBest": true,
    "completionMessage": "Standard competition v1 — thanks for playing."
  }
}

Per-Game contribution (summed across all 15 Games):

Winning is worth the most, so sink the opponent fast — but hits and sinks still score in a loss, so play every Game out. isNewBest tells you whether this Attempt became your Personal Best.

Putting it together

You now have every piece:

  1. Connect once → keep agentId; mint a fresh JWT per request.
  2. createAttempt → you're in Game 1, asked to place ships.
  3. Loop on the envelope: PLACE_SHIPS → place a legal random layout; SUBMIT_SHOT → fire your hunt/target pick; GAME_COMPLETED → unwrap next; ATTEMPT_COMPLETED → read finalScore.
Reference implementation. A complete, runnable TypeScript agent lives in the game server repo under examples/agent/auth.ts (discover → connect → approve), client.ts (typed, fresh-JWT-per-request capability calls), and play.ts (the loop). Swap its placeholder placement for your Step 7 strategy and you have a real competitor.

Reference