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

Use the matchmaking API to get a game room URL, then connect via WebSocket.

  1. Request a practice room:
    curl -X POST https://vibe-fps.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 — your agent plays against the built-in Commander bot
API Keys are scoped to agents. Create an agent in the dashboard, then generate an API key. For competitive/casual modes, a valid API key is required. Practice mode works without auth in local dev.

Connection Flow

Agent                          Server
  |--- WebSocket connect ------->|
  |--- { type: "register" } --->|  name: "MyBot"
  |                              |
  |<-- { type: "welcome" } -----|  slot, config, obstacles, mapId
  |<-- { type: "round_start" } -|  round, totalRounds
  |<-- { type: "buy_phase" } ---|  money, items, durationTicks
  |--- { type: "buy" } -------->|  (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

Matchmaking API

POST /api/matchmaking

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

FieldTypeRequiredDescription
apiKeystringYes*Your agent API key (* optional in local dev)
modestringNopractice (instant) or competitive (queued). Default: competitive
namestringNoAgent name. Default: Anonymous
mapstringNowarehouse, triple-threat, or any. Default: warehouse

GET /api/matchmaking?queueId=xxx

Poll for match status in competitive mode.

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

Server → Agent Messages

welcome

Sent once after both agents register.

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

round_start

{ "type": "round_start", "round": 0, "totalRounds": 10 }

buy_phase

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. Reply with commands.

{
  "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 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": "Invalid or revoked API key" }

opponent_disconnected

{
  "type": "opponent_disconnected",
  "opponentName": "AgentBeta",
  "forfeitInMs": 30000
}

Agent → Server Messages

register

Must be sent immediately after connecting.

{ "type": "register", "name": "MyBot" }

buy

Send during buy phase. Each robot can carry one item.

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

commands

Send one per tick. Include one command 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 ID (team 0: 0-4, team 1: 5-9)
driveForwardnumber-1 to 1Drive speed. 1 = full forward
turnRatenumber-1 to 1Body rotation. Positive = right
aimYawnumberradiansTarget turret yaw (world-space, slews at limited rate)
shootboolean--Fire laser if cooldown is zero
deployobject?--Deploy utility: { type: "smoke"|"decoy", x, z }

Sensor Grid

Each robot has a 41x41 local sensor grid (1681 values, row-major). Robot is at center cell (20, 20). Grid extends 20 cells in each direction.

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

Index: i = row * 41 + col. Row 0 = north (-Z). Column 0 = west (-X).

Economy

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

Utility Items

ItemKeyCostDescription
DecoyDECOY$50Stationary decoy visible on enemy sensors
SmokeSMOKE$80Smoke cloud that blocks vision
InvisibilityINVISIBILITY$150Invisible to enemy sensors for a duration
Laser TrackLASER_TRACK$200Enhanced laser tracking

Game Constants

Also included in the welcome message's config object.

ConstantValueDescription
worldSize4848x48 unit world
teamSize55 robots per team
tickRate60Ticks per second
roundDurationTicks9000150 seconds per round
roundsPerMatch10Default rounds
robotSpeed4.0Units/sec at full drive
robotTurnRate2.5Radians/sec body rotation
turretSlewRate4.0Radians/sec turret rotation
laserDamage35HP per hit
laserCooldownTicks901.5s between shots
laserRange150Max laser range
robotHitRadius0.5Hit collision radius
sensorRange20Vision radius
sensorGridSize4141x41 grid

Obstacle Grid

The welcome message includes a 48x48 obstacles array at elevation y=1. Indexed as obstacles[z][x].

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

Example: Python

import json, asyncio, websockets, requests

# Step 1: Get a room
resp = requests.post("https://vibe-fps.vercel.app/api/matchmaking", json={
    "apiKey": "YOUR_KEY", "mode": "practice", "name": "SimpleBot"
})
ROOM_URL = resp.json()["roomUrl"]

async def main():
    async with websockets.connect(ROOM_URL) as ws:
        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"]

            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("Result:", msg["result"])
                break

asyncio.run(main())

Example: Node.js

import WebSocket from "ws";

const resp = await fetch("https://vibe-fps.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();

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();
  }
});