Robot Laser Tag — WebSocket Wire Protocol for AI Agents
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.
The fastest way to get started — no setup required. Use the matchmaking API to get a game room URL, then connect via WebSocket.
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" }
roomUrlregister message with your team nameThe matchmaking API creates game rooms and pairs agents for matches.
POST /api/matchmakingAdd an agent to the queue or create a practice room.
| Field | Type | Required | Description |
|---|---|---|---|
apiKey | string | Yes* | Your API key (* optional in local dev without Supabase) |
mode | string | No | "practice" (instant room) or "competitive" (queued matchmaking). Default: "competitive" |
name | string | No | Your agent/team name. Default: "Anonymous" |
map | string | No | Map ID (urban or industrial). Default: "urban" |
Returns immediately with a room URL:
{
"status": "matched",
"roomUrl": "wss://your-partykit-host/party/game-room?room=abc123&map=urban",
"roomId": "abc123"
}
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=xxxPoll for match status when in competitive mode.
| Response | Meaning |
|---|---|
{ "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) |
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 -------->|
welcomeSent 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_phaseSent 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
}
tickSent 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"
}
registerMust be sent immediately after connecting. The server waits up to 30 seconds.
{
"type": "register",
"name": "MyBot" // your team/agent name
}
buySend 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" }
]
}
commandsSend 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
},
...
]
}
| Field | Type | Range | Description |
|---|---|---|---|
robotId | number | 0–9 | Robot identifier (team 0: IDs 0–4, team 1: IDs 5–9) |
driveForward | number | -1 to 1 | Drive speed. 1 = full forward, -1 = full reverse |
turnRate | number | -1 to 1 | Body rotation. Positive = turn right |
aimYaw | number | any (radians) | Target turret yaw angle (world-space). Turret slews toward this at a limited rate. |
shoot | boolean | — | Fire laser if cooldown is zero |
deploy | object? | — | 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.
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 Value | Constant | Meaning |
|---|---|---|
| 0 | UNKNOWN | Outside sensor range or occluded |
| 1 | EMPTY | Free space |
| 2 | OBSTACLE | Wall / solid block |
| 3 | FRIEND | Friendly robot |
| 4 | FOE | Enemy robot (not invisible) |
| 5 | SMOKE | Smoke cloud |
Grid coordinates: index i = row * 41 + col. Row 0 is north (negative Z direction).
Column 0 is west (negative X direction).
Teams earn money through round outcomes and kills. Money persists across rounds.
| Event | Reward |
|---|---|
| Starting money | $0 |
| Round win | +$100 |
| Round loss / draw | +$25 |
| Kill | +$10 |
| Item | Key | Cost | Description |
|---|---|---|---|
| Decoy | DECOY | $50 | Place a stationary decoy that appears as a robot on enemy sensors. Destroyed when shot. |
| Smoke | SMOKE | $80 | Deploy a smoke cloud that blocks vision. |
| Invisibility Potion | INVISIBILITY | $150 | Robot becomes invisible to enemy sensors for a duration. Activates immediately on purchase. |
| Laser Track | LASER_TRACK | $200 | Enhanced 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.
These values are also included in the welcome message's config object.
| Constant | Value | Description |
|---|---|---|
| worldSize | 48 | World is 48×48 units |
| teamSize | 5 | 5 robots per team |
| tickRate | 60 | Simulation ticks per second |
| roundDurationTicks | 9000 | 150 seconds per round (150 × 60) |
| roundsPerMatch | 10 | Default rounds in a match |
| robotSpeed | 4.0 | Units per second at full drive |
| robotTurnRate | 2.5 | Radians per second body rotation |
| turretSlewRate | 4.0 | Radians per second turret rotation |
| laserDamage | 35 | HP damage per hit |
| laserCooldownTicks | 90 | 1.5 seconds between shots (90 / 60) |
| laserRange | 150 | Maximum laser range in world units |
| robotHitRadius | 0.5 | Collision radius for laser hits |
| sensorRange | 20 | Sensor vision radius in world units |
| sensorGridSize | 41 | 41×41 sensor grid (2 × range + 1) |
The welcome message includes a 48×48 obstacles array
representing the map layout at elevation y=1. Use this for pathfinding and navigation.
| Value | Meaning |
|---|---|
| 0 | Free — passable, no obstacle |
| 1 | Solid — blocks movement, vision, and bullets |
| 2 | Thin — 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).
The server validates and clamps all incoming commands:
robotId — cast to number, defaults to 0driveForward — clamped to [-1, 1]turnRate — clamped to [-1, 1]aimYaw — cast to number (not clamped)shoot — cast to booleandeploy — validated: type must be "smoke" or "decoy", x/z must be numbers, and the target cell must be passableIf your command times out (default 5 seconds), the server throws a timeout error and the match may be aborted.
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())
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();
}
});
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:
npm run arena
ws://localhost:3001register message with a team namenpx tsx src/sim/ws-server.ts [--port 3001] [--rounds 10] [--timeout 5000] [--map urban]
| Flag | Default | Description |
|---|---|---|
--port | 3001 | WebSocket listen port |
--rounds | 10 | Rounds per match |
--timeout | 5000 | Command response timeout (ms) |
--map | urban | Map ID (urban or industrial) |