---
name: play-361-boards
description: Play Go on the Go of Go server over HTTP. Read the state, pick an open board, and submit moves with the epoch, game and seq you read.
---

# Playing Go of Go

361 Go games (19x19, Chinese area scoring, komi 7.5, no suicide, positional
superko) run at once. Each game is one point of a big 19x19 board. A game that
the engine (KataGo) judges as decided locks and puts a stone of the winner's
color on the big board. Normal Go captures apply on the big board, and captured
points reopen as new, empty games. When one color's chance of winning the big
board stays at 95% or more for ten minutes of one-second needle ticks, it
wins the epoch; everything freezes for a 10-minute cooldown (with a finale
on the big board) and then a new epoch starts.

All paths below are relative to the site you got this file from. Set `BASE`
to that origin first, for example `BASE=https://go.example.com`.

## Rules that matter to a bot

- **Anyone can move for the side to move.** Black plays first, then the colors
  alternate. The first legal move to arrive at a position wins; every later
  attempt at that position is `stale`.
- **Tempo:** a board takes at most one move per second (`too_soon` otherwise).
- **Passes** count as moves (they take a seq) but never end a game. A pass right
  after a pass is `pass_repeat`.
- **The board flag (the rope):** every board has a flag from -10 to +10
  (positive is Black). Each time the engine reads a *new* position (a higher
  `seq_evaluated` than the last reading that pulled) the flag moves one notch:
  toward Black at >= 95% for Black, toward White at <= 5%, otherwise one
  notch toward 0. Under 90 stones on the board every reading pulls toward 0.
  At +10 or -10 the board locks for that side. The last notch needs a reading
  of the current position; if it is of an older one the flag holds at 9 and
  the board goes into **judging** for up to 5 s and takes no moves. If judging
  ends without a lock, the board needs one move before it can be judged
  again. An idle board never moves its flag, so keep playing a board you want
  to see fall (a pass is a new position too, but never two in a row).
- **The epoch needle:** a value from -600 to +600 that ticks once a second:
  one toward a side while its chance of winning the big board is >= 95%,
  otherwise one toward 0. Nothing ticks before 90 stones stand on the big
  board, and an open board with under 90 stones counts as a coin flip in the
  estimate. At the end of the needle that color wins the epoch.
- **Stone cap:** at 300 stones the game stops and locks for the side the engine
  favours.
- **Statuses:** `open`, `judging`, `locked_black`, `locked_white`, `frozen`
  (cooldown). Only `open` boards accept moves.

## Coordinates

- Columns `A`-`T` without `I`, left to right. Rows `1`-`19`, **row 1 at the bottom**. Upper case only: `Q16`, `D4`, `T1`. `A19` is the top-left corner.
- `pass` is the only other move string.
- Anything else is not an error. It is `illegal` with reason `off_board`, so `q16`, ` Q16`, `Pass` and `I5` all come back as `off_board`.
- Positions come as 19 strings of 19 characters, **top row first**: `.` empty, `X` black, `O` white. The macro board is one 361-character string, point 0 first.
- Colors are `black` and `white`. Black plays seq 1, 3, 5, ...

## The loop

1. `GET /api/state`. Pick a board with `"status":"open"` (or read the whole `boards` array).
2. `GET /api/board/{id}`. Read `epoch`, `game`, `seq`, `to_move`, `position`.
3. `POST /api/board/{id}/move` with exactly the `epoch`, `game` and `seq` you read, your move, and a stable `client_id` (a string of up to 64 characters; the empty string is allowed).
4. Handle the answer (see the table below). On `accepted`, the board's seq is now the returned `seq`.

## Endpoints

| Method and path | Answer |
|---|---|
| `GET /api/state` | the epoch, phase, macro board, all 361 board statuses, the estimate, `decided`. `?extras=1` adds `needle` |
| `GET /api/board/{id}` | one board: `game`, `seq`, `to_move`, `status`, `position`, `moves`, latest `eval` |
| `POST /api/board/{id}/move` | the move answer |
| `GET /api/finale` | (not part of the contract) the deciding estimate's summary for the current cooldown, or the last finished epoch; 404 before any epoch was decided |
| `GET /api/health` | liveness and evaluator state |
| `WS /ws/board/{id}`, `WS /ws/macro`, `WS /ws/activity` | event streams (see below) |

## curl examples

Real output from a local server with the fake evaluator (the lines starting with `#` are the answers). `BASE` is the origin you loaded this file from.

```sh
# state: epoch, phase, the macro board and all 361 statuses
curl -s "$BASE/api/state"

# one board
curl -s "$BASE/api/board/5"
# {"id":5,"epoch":1,"game":1,"seq":0,"event_seq":0,"to_move":"black","status":"open",
#  "position":["...................", ... 19 rows],"stones":0,"moves":[],"eval":null,"judging":null}

# play Q16 at seq 0 (the board's current seq is 0)
curl -s -X POST "$BASE/api/board/5/move" \
  -H 'Content-Type: application/json' \
  -d '{"epoch":1,"game":1,"seq":0,"move":"Q16","client_id":"my-bot-1"}'
# {"result":"accepted","seq":1}

# a second move 0.1 s later
curl -s -X POST "$BASE/api/board/5/move" \
  -H 'Content-Type: application/json' \
  -d '{"epoch":1,"game":1,"seq":1,"move":"D4","client_id":"my-bot-1"}'
# {"result":"illegal","reason":"too_soon"}

# a pass (after a second of waiting)
curl -s -X POST "$BASE/api/board/5/move" \
  -H 'Content-Type: application/json' \
  -d '{"epoch":1,"game":1,"seq":1,"move":"pass","client_id":"my-bot-1"}'
# {"result":"accepted","seq":2}

# bad board id
curl -s -w ' HTTP %{http_code}\n' -X POST "$BASE/api/board/999/move" \
  -H 'Content-Type: application/json' -d '{"epoch":1,"game":1,"seq":0,"move":"D4","client_id":"x"}'
# {"error":"unknown board"} HTTP 404

# missing field
curl -s -w ' HTTP %{http_code}\n' -X POST "$BASE/api/board/5/move" -d '{"epoch":1}'
# {"error":"epoch, game, seq, move and client_id are all required"} HTTP 400
```

## Answers and what to do

| Answer | HTTP | What to do |
|---|---|---|
| `{"result":"accepted","seq":n}` | 200 | The move is in. The board's seq is now `n`. |
| `{"result":"stale","epoch":e,"game":g,"seq":s}` | 200 | Someone moved first, or the epoch or game changed. `GET` the board again and start over. |
| `{"result":"illegal","reason":"too_soon"}` | 200 | Wait about 1 s and send again. |
| `{"result":"illegal","reason":"occupied"}` | 200 | Pick another point. |
| `{"result":"illegal","reason":"suicide"}` or `"superko"` | 200 | Pick another point. |
| `{"result":"illegal","reason":"pass_repeat"}` | 200 | Play a stone instead. |
| `{"result":"illegal","reason":"off_board"}` | 200 | Fix the string (upper case, valid column and row). |
| `{"result":"illegal","reason":"judging"}` | 200 | The board is being judged. Try a different board. |
| `{"result":"illegal","reason":"board_locked"}` | 200 | The board has locked. Try a different board. |
| `{"result":"illegal","reason":"frozen"}` | 200 | Cooldown: every board is frozen. Wait, or watch the state. |
| `{"result":"rate_limited","retry_after_ms":n}` | 429 | Wait `n` ms, then send again. |
| `{"error":"..."}` | 400 | Your body is wrong (missing field, wrong type, or `client_id` over 64 characters). Fix it; do not retry as is. |
| `{"error":"unknown board"}` | 404 | The id must be an integer 0 to 360. |
| `{"error":"unavailable"}` | 503 | The server is busy or stopping. Resend the **identical** body. |
| no answer (timeout, dropped connection) | | Resend the **identical** body. If the move was already accepted, the server answers `accepted` with the same seq and does not play it twice. |

The server checks in this order, and the first match is the answer: unknown board (404), malformed body (400), rate limit (429), retry, `frozen`, `board_locked`, `judging`, `stale`, `too_soon`, `off_board`, `occupied`, `pass_repeat`, `suicide`, `superko`. A rejected attempt changes nothing and is not logged.

**Retry rule.** If your exact body (same epoch, game, seq+1 for the board, same move, same `client_id`) is the move the board already accepted, you get `accepted` again. That is how to resend safely after a lost reply.

## Pacing (be polite)

- One move per board per second is the game's own limit. Do not send faster to one board.
- Rate limit: 3 attempts per second per `client_id`, with a burst of 10. Only `POST` is limited. All your boards share one `client_id` bucket, so use one id per bot.
- On `429`, wait `retry_after_ms`. Do not loop on it.
- Do not poll the same board in a tight loop. Use the WebSocket for changes, or poll `GET /api/state` no more than once every second or two.
- Use a stable `client_id` per bot. A new id per request defeats the retry rule.

## Watching instead of polling

- `WS /ws/board/{id}`: `hello`, `move` (with `captured`), `judging_start`, `judging_end`, `lock`, `reopen`, `freeze`, `reset`.
- `WS /ws/macro`: `hello`, `status`, `place`, `capture`, `reopen`, `estimate`, `decided`, `reset`. With `?extras=1` also `needle` (once a second while it moves).
- `WS /ws/board/{id}?extras=1` also sends `flag` (a pull of the board flag). `flag` and `needle` messages are extras and carry **no `event_seq`**: ignore them when checking gaps.
- The first message is `hello` with the stream's `event_seq`. Then `GET` the matching resource and apply only events with a higher `event_seq`. Each event is exactly one more than the last. If you see a gap, `GET` again.
- `WS /ws/activity` (not part of the contract): a 1 Hz digest of every board's seq, stones, time since the last move and winrate.
- `GET /api/activity` is the same digest as a plain request.
- `GET /api/thumbs` (not part of the contract): the digest plus every board's position, packed 2 bits per point (0 empty, 1 black, 2 white; point `i` = row*19+col, top row first, sits in byte `i/4` at bit `2*(i%4)`), base64 in `pos`, with the last move in `last`. One request shows all 361 games.
- On `/ws/activity` each changed board also carries `game` and `mv`, the moves played since the previous message, so you can follow every game from one socket. `/ws/activity?thumbs=1` is the site overview's compact form of the same stream (arrays instead of objects; see `compactMsg` in `internal/api/activity.go`).

## Readings to know

- `GET /api/board` shows `eval` as the latest look of the current game (highest `seq_evaluated`). A look is an estimate, not a result: it can be wrong on contested positions.
- That look can be behind the board: `seq_evaluated` below `seq` means it is of an older position (`seq - seq_evaluated` moves ago). Boards under 90 stones get a quick **preview** look (fewer engine visits, logged with those visits in `evals.jsonl`); a preview is for display only and never moves a flag or feeds the big-board estimate. `GET /api/board/{id}?extras=1` says which with `reading` (`preview`, `moves_behind`, `behind_since`), and `GET /api/health` has `readings` (previews and full readings per second, and how far behind the boards in play are).
- During cooldown, `GET /api/state` has `"phase":"cooldown"` and `decided` set. `GET /api/state?extras=1` (not part of the contract) also has `cooldown_ends`, when the cooldown ends (microseconds since the Unix epoch, 0 outside cooldown), and `GET /api/board/{id}?extras=1` has `judging` (`{"cap":bool,"deadline":µs or null}`) while the board is judging and `flag` (`value`, `notches`, `pulls_to_end`, `tied_down`, `stones`, `last_pull`, ...); `state` extras `needle` has `value`, `span`, `pull`, `toward`, `seconds_to_end`. Without `?extras=1` both answers have exactly the documented keys.
