# Vibe Duels wire protocol (v1)

One match per WebSocket connection to `wss://vibeduels.com/ws`. Every frame is a JSON text message with a
`type`. The view the server sends and the command you answer with are exactly the objects described in
[ARENA.md](https://vibeduels.com/kit/ARENA.md), so a bot that works in the offline sandbox works here unchanged.

## 1. You open with `hello`

```json
{
  "type": "hello",
  "token": "vd_...",
  "bot": { "name": "Up to 20 chars", "motto": "Up to 60 chars", "stats": { "armor": 3, "engine": 3, "gun": 2, "reload": 2 } },
  "mode": "practice",
  "vs": "gravity",
  "rounds": 8,
  "seed": 12345
}
```

- `mode`: `practice`, `ranked` or `lobby`.
- `vs` (practice and ranked): a control id from `GET /api/controls`. Defaults to the first tier.
- `rounds` (practice and lobby): 1 to 32, default 8. Maps rotate and sides swap, so 8 rounds is every map from
  both sides.
- `seed` (practice and lobby, optional): an unsigned 32-bit integer. The same seed reproduces the same match
  conditions (spawns, control variant). Omit it and the server draws one.
- `room` (lobby, optional): 1 to 32 letters, digits, `_` or `-`. See "The lobby" below.

Ranked runs ignore `rounds` and `seed`: the run is the season's length (32 rounds) on seeds the server draws and
reveals only in the replay.

### The lobby

`mode: "lobby"` plays another entrant instead of a house bot. Unranked: it never touches the ladder. The first
entrant to arrive waits; the next to arrive is paired with them. A `room` code makes a private queue, so two
friends who use the same code meet each other and nobody else. While you wait the server sends

```json
{ "type": "waiting", "mode": "lobby", "room": "friends", "rounds": 8, "message": "waiting in room \"friends\" for an opponent" }
```

and then the usual `welcome` once an opponent arrives. If nobody comes within the wait limit (five minutes by
default, `GET /api/season` has it) you get `{ "type": "error", "code": "lobby_timeout" }` and a close. The match
plays the shorter of the two requested round counts, seeded by the first arrival's `seed` if it gave one. Lobby
rounds count against the practice budget.

## 2. The server answers `welcome`

```json
{ "type": "welcome", "protocol": 1, "matchId": "k3J9...", "mode": "practice", "season": "s1",
  "you": { "handle": "nick", "name": "Nameless", "stats": { "...": 0 } },
  "opponent": { "id": "gravity", "name": "Gravity", "motto": "...", "stats": { "...": 0 }, "tier": 5 },
  "rounds": 8, "tickTimeoutMs": 250, "replayUrl": "https://vibeduels.com/r/k3J9..." }
```

In a lobby match `opponent` is the other entrant, `{ "handle": "sam", "name": "...", "motto": "...", "stats": {} }`,
and the welcome also carries `room` and `seat` (0 if you arrived first, 1 otherwise; the replay's `players`
are in seat order).

Or `{ "type": "error", "code": "...", "message": "..." }` followed by a close. Codes: `bad_token`, `bad_stats`,
`bad_mode`, `bad_control`, `bad_room`, `rate_limited`, `too_many`, `busy`, `lobby_timeout`, `hello_timeout`,
`bad_json`, `expected_hello`.

## 3. Each round

```json
{ "type": "round", "index": 0, "of": 8, "mapIndex": 0, "mapName": "Proving Ground", "side": 0 }
{ "type": "init",  "info": { "round": 0, "side": 0, "mapName": "Proving Ground", "view": { "...": "the full state" } } }
```

`init` is what a local bot's `init(info)` receives. No reply is expected.

Then, for every tick of the round:

```json
{ "type": "tick", "view": { "tick": 17, "time": 0.566, "me": { "...": 0 }, "enemy": { "...": 0 }, "bullets": [], "...": 0 } }
```

and you answer:

```json
{ "type": "action", "tick": 17, "action": { "throttle": 1, "turn": 0, "turretTurn": -0.3, "fire": true } }
```

`tick` must echo the view's tick; actions for any other tick are ignored. Values are clamped to [−1, 1];
garbage counts as zero; a null action stands still.

**Deadline.** You have `tickTimeoutMs` (250 by default) from the moment the server sends the tick. A late or
missing action repeats your previous one and counts as missed. If nothing arrives for `FREEZE_MS` (5 s) the
server stops waiting the full budget per tick and the tank keeps repeating its last command until actions arrive
again (`{ "type": "notice", "code": "frozen" }`, then `"resumed"`). After four times that with nothing at all,
the match is aborted.

The server sends the next tick as soon as it has stepped the previous one, so a fast client plays faster than
real time and a slow link plays slower. In a lobby match the server sends both views, waits for both actions
(each under its own deadline), then steps: the match runs at the pace of the slower client, and the rules above
apply to each side separately. Nobody watches live; everybody watches the replay.

At the end of the round:

```json
{ "type": "roundOver", "index": 0, "youWon": true, "draw": false, "reason": "kill", "time": 23.4,
  "hp": { "you": 97, "enemy": 0 }, "maxHp": { "you": 204, "enemy": 176 }, "margin": 0.475, "missedSoFar": 0 }
```

`margin` is your HP share minus the opponent's, in [−1, 1].

## 4. The match ends with `matchOver`, then the socket closes

```json
{ "type": "matchOver", "matchId": "k3J9...", "status": "done", "mode": "ranked", "control": "gravity",
  "opponent": { "id": "gravity", "name": "Gravity", "motto": "...", "stats": {}, "tier": 5 },
  "score": { "wins": 29, "losses": 2, "draws": 1, "played": 32, "of": 32 },
  "rate": 0.922, "margin": 0.31, "missed": 3, "notable": "first-clear", "cleared": true,
  "replayUrl": "https://vibeduels.com/r/k3J9..." }
```

`rate` counts a draw as half a win. In a ranked run, `cleared` means `rate` reached the season's clear rate
(0.9). If you disconnect during a ranked run, the remaining rounds count as losses at margin −1.

In a lobby match `control` is null, `opponent` is the other entrant's card, and the score is yours (the other
client receives the mirror image). If the match was cut short because the other side disconnected, `left`
names who left (`"@sam"`).

## REST

| Call | What |
|---|---|
| `POST /api/register` `{ "handle": "..." }` | Create an account; returns `{ handle, token }` once. Keep the token private. |
| `GET /api/controls` | The house bots: id, tier, name, motto, stats, colour, art. |
| `GET /api/season` | Season id, tiers, ranked length, clear rate, per-tick budget, limits, lobby settings. |
| `GET /api/me` (Bearer token) | Your recent matches. |
| `GET /api/users/:handle` | Anyone's recent matches. |
| `GET /api/matches/:id` | One match's summary. |
| `GET /api/replays/:id` | The replay file (gzip JSON). The viewer at `/r/:id` plays it. |
| `GET /api/feed`, `GET /api/leaderboard` | What the site shows. |

A match summary has `mode`, the first entrant's `handle` and `bot`, and either `control` (practice, ranked) or
`opponent: { handle, bot }` (lobby). Scores read for the first entrant; on an entrant's own page every lobby match
is shown from their chair. Lobby matches appear in the feed tagged `lobby` and never on the leaderboard.

## Replay format (v2)

```json
{ "v": 2, "id": "...", "mode": "ranked", "season": "s1", "createdAt": 0, "tickTimeoutMs": 250,
  "players": [
    { "kind": "entrant", "handle": "nick", "name": "Nameless", "motto": "", "stats": {}, "color": "#..." },
    { "kind": "control", "id": "gravity", "name": "Gravity", "motto": "...", "stats": {}, "color": "#...",
      "art": { "body": "/contestants/gravity/tank/body.svg", "turret": "..." }, "variant": { "HZ": 25 } }
  ],
  "rounds": [ { "index": 0, "mapIndex": 0, "seed": 123456789, "swap": false,
                "actions": [ [[1, 0, -0.3, 1], "...side 0, one entry per tick"], ["...side 1"] ],
                "result": { "winner": 0, "reason": "kill", "time": 23.4, "ticks": 702, "hp": [97, 0], "maxHp": [204, 176], "tally": [{}, {}] } } ],
  "summary": { "status": "done", "played": 8, "wins": 5, "losses": 3, "draws": 0, "margin": 0.12, "missed": [0, 0] } }
```

`players` are the two cards in seat order: `[entrant, control]` for practice and ranked, `[entrant, entrant]` for
a lobby match. `swap: true` means `players[0]` played side 1 (started on the right). `summary` reads for
`players[0]`; `summary.missed` is per seat. Re-simulating with `createRound({ mapIndex, tanks, seed })` from the
public engine and stepping with the recorded actions reproduces the round exactly.

v1 replays (before the lobby) carry `contestant` and `control` in place of `players`, with `summary.missed` a
single number for the contestant; the viewer still plays them.
