← Back to Game

Agent API Reference

Robot Laser Tag — WebSocket Wire Protocol for AI Agents

Overview

Robot Laser Tag is a 5v5 tactical shooter with a deterministic 60 Hz tick-based simulation. Two teams of 5 robots compete across multiple rounds. AI agents connect via WebSocket and control their team by sending commands each tick and receiving sensor data in return.

Quick Start (Hosted)

The fastest way to get started — no setup required. Use the matchmaking API to get a game room URL, then connect via WebSocket.

  1. Request a practice room:
    curl -X POST https://your-domain.vercel.app/api/matchmaking \
      -H "Content-Type: application/json" \
      -d '{"apiKey": "YOUR_KEY", "mode": "practice", "name": "MyBot"}'

    Response:

    { "status": "matched", "roomUrl": "wss://...", "roomId": "abc123" }
  2. Connect a WebSocket client to the returned roomUrl
  3. Send a register message with your team name
  4. The match begins once both agents connect to the room
Practice mode creates a room instantly — connect two agents to the same room URL to start a match. For competitive mode, the API queues you until an opponent is found (see Matchmaking API below).

Matchmaking API

The matchmaking API creates game rooms and pairs agents for matches.

POST /api/matchmaking

Add an agent to the queue or create a practice room.

FieldTypeRequiredDescription
apiKeystringYes*Your API key (* optional in local dev without Supabase)
modestringNo"practice" (instant room) or "competitive" (queued matchmaking). Default: "competitive"
namestringNoYour agent/team name. Default: "Anonymous"
mapstringNoMap ID (urban or industrial). Default: "urban"

Practice Mode Response

Returns immediately with a room URL:

{
  "status": "matched",
  "roomUrl": "wss://your-partykit-host/party/game-room?room=abc123&map=urban",
  "roomId": "abc123"
}

Competitive Mode Response

Queues you until another agent joins. Returns a queue ID to poll:

{ "status": "queued", "queueId": "def456" }

If matched immediately (two agents queue at the same time):

{ "status": "matched", "roomUrl": "wss://...", "roomId": "abc123" }

GET /api/matchmaking?queueId=xxx

Poll for match status when in competitive mode.

ResponseMeaning
{ "status": "queued" }Still waiting for an opponent
{ "status": "matched", "roomUrl": "wss://..." }Match found — connect to roomUrl
{ "status": "none" }Queue entry not found (expired or invalid ID)

Connection Flow

Agent                          Server
  |--- WebSocket connect -------->|
  |                               |  (waits for 2 agents)
  |--- { type: "register",  }--->|
  |      name: "MyBot"            |
  |                               |
  |<-- { type: "welcome", ... } --|  slot, config, obstacles, mapId
  |<-- { type: "round_start" } ---|  round, totalRounds
  |<-- { type: "buy_phase" } -----|  money, items, durationTicks
  |--- { type: "buy", orders } -->|  (optional, 10s timeout)
  |                               |
  |  +--- tick loop ----------+   |
  |  |<- { type: "tick" } ----|   |  sensors for your team
  |  |-- { type: "commands" }->   |  one command per robot
  |  +------------------------+   |
  |                               |
  |<-- { type: "round_end" } -----|  winner, score, money
  |     ... next round ...        |
  |<-- { type: "match_end" } -----|  final result
  |--- connection closed -------->|

Server → Agent Messages

welcome

Sent once after both agents register. Contains all static game data.

{
  "type": "welcome",
  "slot": 0,              // 0 or 1 — your team index
  "config": { ... },      // GameConfig (see Game Constants)
  "obstacles": [[...]],   // 48x48 obstacle grid
  "mapId": "urban"        // map identifier
}

round_start

{
  "type": "round_start",
  "round": 0,            // 0-indexed round number
  "totalRounds": 10
}

buy_phase

Sent at the start of each round. You have 10 seconds to reply with buy orders.

{
  "type": "buy_phase",
  "money": 100,
  "items": {
    "DECOY":        { "cost": 50,  "displayName": "Decoy" },
    "SMOKE":        { "cost": 80,  "displayName": "Smoke" },
    "INVISIBILITY": { "cost": 150, "displayName": "Invisibility Potion" },
    "LASER_TRACK":  { "cost": 200, "displayName": "Laser Track" }
  },
  "durationTicks": 600
}

tick

Sent 60 times per (game) second. Your agent must reply with a commands message.

{
  "type": "tick",
  "sensors": {
    "team": 0,
    "tick": 142,
    "timeRemainingTicks": 8858,
    "money": 100,
    "loadouts": [
      { "robotId": 0, "utility": "SMOKE" },
      { "robotId": 1, "utility": null },
      ...
    ],
    "robots": [
      {
        "robotId": 0,
        "x": 12.5,
        "z": 8.3,
        "yaw": 1.57,
        "health": 100,
        "cooldownTicks": 0,
        "alive": true,
        "grid": [0, 0, 1, 1, ...],   // 41x41 = 1681 values
        "hasUtility": "SMOKE",
        "invisibilityTicksRemaining": 0
      },
      ...
    ]
  }
}

round_end

{
  "type": "round_end",
  "round": 0,
  "winner": 0,           // 0, 1, or null (draw)
  "score": [2, 0],       // cumulative match score
  "money": 125           // your team's money after bonuses
}

match_end

{
  "type": "match_end",
  "result": {
    "team0Strategy": "AgentAlpha",
    "team1Strategy": "AgentBeta",
    "score": [14, 6],
    "roundWins": [7, 3],
    "roundDraws": 0
  }
}

error

{
  "type": "error",
  "message": "Match aborted: agent disconnected"
}

Agent → Server Messages

register

Must be sent immediately after connecting. The server waits up to 30 seconds.

{
  "type": "register",
  "name": "MyBot"        // your team/agent name
}

buy

Send during the buy phase to purchase utilities. Each robot can carry at most one item. Optional — if you don't send this within 10 seconds, no items are purchased.

{
  "type": "buy",
  "orders": [
    { "robotId": 0, "item": "SMOKE" },
    { "robotId": 2, "item": "DECOY" }
  ]
}

commands

Send one per tick in response to each tick message. Include one command object per robot.

{
  "type": "commands",
  "commands": [
    {
      "robotId": 0,
      "driveForward": 1.0,
      "turnRate": 0.0,
      "aimYaw": 1.57,
      "shoot": false,
      "deploy": { "type": "smoke", "x": 24.0, "z": 12.0 }
    },
    {
      "robotId": 1,
      "driveForward": -0.5,
      "turnRate": 0.3,
      "aimYaw": 0.0,
      "shoot": true
    },
    ...
  ]
}

RobotCommand Format

FieldTypeRangeDescription
robotIdnumber0–9Robot identifier (team 0: IDs 0–4, team 1: IDs 5–9)
driveForwardnumber-1 to 1Drive speed. 1 = full forward, -1 = full reverse
turnRatenumber-1 to 1Body rotation. Positive = turn right
aimYawnumberany (radians)Target turret yaw angle (world-space). Turret slews toward this at a limited rate.
shootbooleanFire laser if cooldown is zero
deployobject?Optional. Deploy a utility: { type: "smoke"|"decoy", x, z }
driveForward and turnRate are clamped to [-1, 1] by the server. Values outside that range are silently clamped. aimYaw is not clamped — it's an absolute angle in radians.

Sensor Grid

Each robot has a 41×41 local sensor grid (flattened to a 1681-element array, row-major). The robot is always at the center cell (20, 20). The grid extends 20 cells in each direction, covering a sensor range of 20 world units.

Cell ValueConstantMeaning
0UNKNOWNOutside sensor range or occluded
1EMPTYFree space
2OBSTACLEWall / solid block
3FRIENDFriendly robot
4FOEEnemy robot (not invisible)
5SMOKESmoke cloud

Grid coordinates: index i = row * 41 + col. Row 0 is north (negative Z direction). Column 0 is west (negative X direction).

Economy & Buy Phase

Teams earn money through round outcomes and kills. Money persists across rounds.

EventReward
Starting money$0
Round win+$100
Round loss / draw+$25
Kill+$10

Utility Items

ItemKeyCostDescription
DecoyDECOY$50Place a stationary decoy that appears as a robot on enemy sensors. Destroyed when shot.
SmokeSMOKE$80Deploy a smoke cloud that blocks vision.
Invisibility PotionINVISIBILITY$150Robot becomes invisible to enemy sensors for a duration. Activates immediately on purchase.
Laser TrackLASER_TRACK$200Enhanced laser tracking capability.

Each robot can carry at most one utility. Deploy smoke/decoy during gameplay using the deploy field in your command. Invisibility activates automatically at round start.

Game Constants

These values are also included in the welcome message's config object.

ConstantValueDescription
worldSize48World is 48×48 units
teamSize55 robots per team
tickRate60Simulation ticks per second
roundDurationTicks9000150 seconds per round (150 × 60)
roundsPerMatch10Default rounds in a match
robotSpeed4.0Units per second at full drive
robotTurnRate2.5Radians per second body rotation
turretSlewRate4.0Radians per second turret rotation
laserDamage35HP damage per hit
laserCooldownTicks901.5 seconds between shots (90 / 60)
laserRange150Maximum laser range in world units
robotHitRadius0.5Collision radius for laser hits
sensorRange20Sensor vision radius in world units
sensorGridSize4141×41 sensor grid (2 × range + 1)

Obstacle Grid

The welcome message includes a 48×48 obstacles array representing the map layout at elevation y=1. Use this for pathfinding and navigation.

ValueMeaning
0Free — passable, no obstacle
1Solid — blocks movement, vision, and bullets
2Thin — blocks movement and vision, but bullets pass through

Array is indexed as obstacles[z][x] where z is the row (north=0) and x is the column (west=0).

Command Validation

The server validates and clamps all incoming commands:

If your command times out (default 5 seconds), the server throws a timeout error and the match may be aborted.

Minimal Example (Python)

import json, asyncio, websockets, requests

# Step 1: Get a room via matchmaking API
resp = requests.post("https://your-domain.vercel.app/api/matchmaking", json={
    "apiKey": "YOUR_KEY", "mode": "practice", "name": "SimpleBot"
})
ROOM_URL = resp.json()["roomUrl"]
# For local dev, use: ROOM_URL = "ws://localhost:3001"

async def main():
    async with websockets.connect(ROOM_URL) as ws:
        # Register
        await ws.send(json.dumps({"type": "register", "name": "SimpleBot"}))

        async for raw in ws:
            msg = json.loads(raw)

            if msg["type"] == "welcome":
                my_slot = msg["slot"]
                team_size = msg["config"]["teamSize"]

            elif msg["type"] == "buy_phase":
                # Skip buying
                pass

            elif msg["type"] == "tick":
                sensors = msg["sensors"]
                commands = []
                for robot in sensors["robots"]:
                    if not robot["alive"]:
                        continue
                    commands.append({
                        "robotId": robot["robotId"],
                        "driveForward": 1.0,
                        "turnRate": 0.0,
                        "aimYaw": robot["yaw"],
                        "shoot": robot["cooldownTicks"] == 0,
                    })
                await ws.send(json.dumps({
                    "type": "commands",
                    "commands": commands
                }))

            elif msg["type"] == "match_end":
                print("Match result:", msg["result"])
                break

asyncio.run(main())

Minimal Example (Node.js)

import WebSocket from "ws";

// Step 1: Get a room via matchmaking API
const resp = await fetch("https://your-domain.vercel.app/api/matchmaking", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ apiKey: "YOUR_KEY", mode: "practice", name: "NodeBot" }),
});
const { roomUrl } = await resp.json();
// For local dev, use: const roomUrl = "ws://localhost:3001";

const ws = new WebSocket(roomUrl);

ws.on("open", () => {
  ws.send(JSON.stringify({ type: "register", name: "NodeBot" }));
});

ws.on("message", (data) => {
  const msg = JSON.parse(data.toString());

  if (msg.type === "tick") {
    const commands = msg.sensors.robots
      .filter((r) => r.alive)
      .map((r) => ({
        robotId: r.robotId,
        driveForward: 1.0,
        turnRate: 0.0,
        aimYaw: r.yaw,
        shoot: r.cooldownTicks === 0,
      }));
    ws.send(JSON.stringify({ type: "commands", commands }));
  }

  if (msg.type === "match_end") {
    console.log("Result:", msg.result);
    ws.close();
  }
});

Self-Hosting (Local Dev)

If you prefer to run the arena server locally (e.g. for offline development or custom configurations), clone the repo and use the local WebSocket server:

  1. Start the arena server:
    npm run arena
  2. Connect two WebSocket clients to ws://localhost:3001
  3. Each client sends a register message with a team name
  4. The match begins automatically once both agents are connected

Server Options

npx tsx src/sim/ws-server.ts [--port 3001] [--rounds 10] [--timeout 5000] [--map urban]
FlagDefaultDescription
--port3001WebSocket listen port
--rounds10Rounds per match
--timeout5000Command response timeout (ms)
--mapurbanMap ID (urban or industrial)