Play API
Anything a player can do in a DipGo game room, a program can do through this API: find and join games, move, pass, resign, agree on dead stones, and use the public and the team chat. It is for computer players and for people building their own front end.
Getting a token
The API authenticates with a personal access token. A person creates one, signed in, at /dashboard/api-tokens; it is shown once, and can be revoked there at any time. Every token the account has is also revoked when its password or email address changes, so a program needs a new one after that. That page is not linked from anywhere else on the site; this guide, llms.txt and the OpenAPI description are the way to it. Send it on every request:
Authorization: Bearer dg_…
A token has every right the account has, so give each program an account of its own and a token of its own, and treat the token like a password. There are no bot accounts and no bot flag: to everyone else on the site, a program is simply the player whose token it holds.
Everything lives under https://dipgo.net/api/v1, takes and returns JSON, and never sets a cookie.
Cross-origin requests are allowed, so a front end hosted elsewhere can call it from a browser.
Quick start
Who am I?
curl -H "Authorization: Bearer $DIPGO_TOKEN" https://dipgo.net/api/v1/me
Is it my turn anywhere?
curl -H "Authorization: Bearer $DIPGO_TOKEN" "https://dipgo.net/api/v1/games?awaitingMe=true"
Read a game, then play at column 3, row 4 (counted from 0, from the top-left corner):
curl -H "Authorization: Bearer $DIPGO_TOKEN" https://dipgo.net/api/v1/games/4275
curl -H "Authorization: Bearer $DIPGO_TOKEN" -H "Content-Type: application/json" \
-d '{"action":"play","x":3,"y":4}' https://dipgo.net/api/v1/games/4275/moves
Every action answers with {"result": "played", "game": {…}}: what happened, and the game as it now stands,
so one round trip is enough to act and see the result.
Find a public game to join, and take a seat on team 2:
curl -H "Authorization: Bearer $DIPGO_TOKEN" https://dipgo.net/api/v1/games/open
curl -H "Authorization: Bearer $DIPGO_TOKEN" -H "Content-Type: application/json" \
-d '{"team":2}' https://dipgo.net/api/v1/games/4300/join
Endpoints
The full description, with every request and response schema, is the OpenAPI document.
| Method | Path | What it does |
|---|---|---|
| GET | /me | The account behind the token. |
| GET | /tokens | The account's live tokens (names and prefixes, never secrets). |
| DELETE | /tokens/{id} | Revoke a token. |
| GET | /games | The account's games. ?status=waiting|confirming|playing|scoring|finished; ?awaitingMe=true keeps only games that need the account to act. |
| GET | /games/open | Public rooms still looking for players. |
| POST | /games | Create a room: name, numberOfTeams (2–9), playersPerTeam (1–4), boardSize (5–25), secondsPerMove, captures, removeStones, komi, allowedTimeouts, unlisted. |
| GET | /games/{id} | The game state (below). |
| GET | /games/{id}/sgf | The record as SGF. |
| POST | /games/{id}/join | Join a room; {"team": 2} also takes a seat. A private room needs {"key": "…"}, the key from its link (its players see it as joinKey on the game), unless the caller was invited. Without either, the room does not exist as far as the caller is concerned: the join, GET /games/{id}, its record and its chat are all 404 until the game starts, so ids cannot be walked to find one. The join that fills a private room starts the game, so there a taken seat refuses the join. |
| POST | /games/{id}/team | {"team": n}: move to a team in a room already joined. |
| POST | /games/{id}/confirm | Confirm participation once a public room is full. A private room starts on the join that fills it and needs no confirmation. |
| POST | /games/{id}/leave | Leave a room that has not started. |
| POST | /games/{id}/kick | Creator only: {"accountId": n} removes a player. |
| POST | /games/{id}/cancel | Creator only: cancel an unstarted room. |
| POST | /games/{id}/invite | {"to": "handle or email"}. |
| POST | /games/{id}/moves | {"action":"play","x":3,"y":4}, {"action":"pass"} or {"action":"resign"}. |
| POST | /games/{id}/scoring/toggle | {"x":3,"y":4}: mark the group there dead, or alive again. |
| POST | /games/{id}/scoring/accept | Accept the dead-stone marking for your team. The game ends when every team present has. |
| POST | /games/{id}/scoring/resume | Disagree: go back to playing. The passes that ended play still stand, so a pass, or a missed turn, from the player to move ends play again at once; play a stone to go on. |
| GET, POST | /games/{id}/chat | The public chat, open to spectators too. {"message": "…"}, up to 200 characters. |
| GET, POST | /games/{id}/team-chat | The private chat, players only. {"message": "…", "recipients": [1, 3]}; your own team is always included. |
How a game proceeds: a room is waiting until every seat is taken, then a public room is confirming until every player confirms, then playing. A private (unlisted) room skips the confirmations and is playing as soon as it fills. When every remaining team passes in turn the game pauses for scoring, where teams mark dead stones and accept the marking; when all have accepted it is finished. A team whose players have all resigned or timed out is out, and its turns are skipped.
The game state
GET /games/{id} is one document with everything in it. Abridged:
{
"id": 4275, "name": "The Hive Five", "status": "playing",
"boardSize": 21, "numberOfTeams": 5, "playersPerTeam": 1,
"rules": {"captures": false, "removeStones": false, "allowedTimeouts": 5,
"secondsPerMove": 86400, "komi": {}, "scoring": "area",
"superko": "positional", "suicide": false},
"teams": [{"number": 1, "out": false, "captures": 0,
"players": [{"accountId": 12, "handle": "alice", "seat": 1,
"confirmed": true, "resigned": false, "timedOut": false, "bot": false}],
"score": {"points": 115, "komi": 0, "captures": 0, "total": 115}}, …],
"turn": {"team": 2, "accountId": 15, "handle": "bob", "deadline": "2026-10-15T12:00:00Z", "isYou": false},
"you": {"accountId": 12, "team": 1, "seat": 1, "confirmed": true, "resigned": false, "mustAct": false},
"board": [".....", "..1..", ".2.3.", …],
"lastMove": {"number": 545, "team": 5, "accountId": 19, "type": "stone", "x": 3, "y": 3, "at": "…"},
"moves": [ … ],
"scoring": null,
"result": null,
"sgf": "(;FF[4]GM[1]…)",
"links": {"web": "https://dipgo.net/dashboard/game/4275", "self": "https://dipgo.net/api/v1/games/4275"}
}
- Coordinates.
xis the column, 0 at the left;yis the row, 0 at the top. The same as SGF. The labels on the web page ("G15") skip the letter I and count rows from the top. - board is one string per row, top row first:
.for an empty point, the team number for a stone. Dead stones stay on the board during and after scoring;scoring.deadStoneslists them. - moves[].type is
stone,pass,resign,timeout(the player was removed) ormissedTurn(a forgiven timeout: the player stays, the turn is lost). - turn is present only while the game is playing. you.mustAct is true when the game is waiting on you: your move, your confirmation, or your team's acceptance of the marking.
- scoring carries
deadStones,acceptedTeamsandpresentTeamsduring the agreement; result carrieswinners,annulledand every team's score once the game is finished. - score per team is DipGo's area score as it stands: stones plus territory, plus captures if the game counts them, plus komi.
Errors
Every error is {"error": "code", "message": "a sentence"}. Switch on the code; show the message.
| Status | Codes |
|---|---|
| 400 | validation, bad_request, off_board, empty_point |
| 401 | unauthorised: no token, or one that was revoked |
| 403 | forbidden: not a player in the game, or not the creator |
| 404 | not_found: no such game, or no such route |
| 405 | method_not_allowed, with an Allow header |
| 406 | not_acceptable: the Accept header rules out JSON |
| 415 | unsupported_media_type: the body was not sent as application/json |
| 409 | not_your_turn, occupied, suicide, ko, already_resigned,
game_not_started, game_finished, in_scoring_phase, not_in_scoring_phase,
cannot_score, cannot_join, team_full, cannot_leave, cannot_confirm, cannot_kick,
cannot_cancel, cannot_invite, no_team, rejected |
| 429 | rate_limited, with a Retry-After header: the account's budget for the minute is spent, or the flood defence in front of the API has held this token (or, for calls with no token, this address) to a few requests a second; the message says which |
Limits and manners
- Each account may make 120 requests a minute, across all its tokens. The reply carries
X-RateLimit-Remaining; a 429 carriesRetry-After. - There is no push channel.
GET /games/{id}answers304 Not ModifiedtoIf-None-Matchwith the ETag it last gave, so polling an unchanged game costs almost nothing to transfer. To learn whether anything needs you, pollGET /games?awaitingMe=truerather than every game. - Poll a live game no more than once a second, and a correspondence game far less often: the state carries
rules.secondsPerMoveandturn.deadlinefor exactly this. - Send a
User-Agentthat names your program, so that we can get in touch if something goes wrong. - A program is a player. It is bound by the same rules of conduct as everyone else; the chat is for people.
SGF
Every game state carries the record as SGF in its sgf field, and GET /games/{id}/sgf serves it on its own.
A two-team game is ordinary FF[4]: B and W moves, team-mates in PB and PW
separated by & , KM, RE. Any SGF tool can open it.
Go with three or more colours has no standard notation, so games with three or more teams use SGF-MC,
a profile of FF[4] designed for DipGo. Its moves are X[colour:point] with the colour's number, a seat
number in XS when teams have more than one player, a player leaving in XE[colour:seat:R|T|M] (resigned, timed out, missed a turn),
an AE setup node when an eliminated team's stones are removed, and the result in RX and XW.
A tool that knows only two colours shows an empty board rather than a wrong one. Captures are computed on replay:
every adjacent enemy group left without liberties is removed together.
Points are two letters, column then row, a = 0, from the top-left; the letter i is not skipped.
For language models: /llms.txt says the same in brief.
