Skip to content

API Specification

This document details the REST API endpoints provided by the dicechess-analytics backend.


The OpenAPI specification is generated from the Tapir endpoint definitions (src/main/scala/dicechess/analytics/api/Endpoints.scala), so it can never drift from the implementation. Browse it locally at:


dicechess-analytics restricts game insertion to trusted sources. The ingestion API is not public and uses a different authentication model.

See the Game Ingestion page for the POST /api/games endpoint specification.


Retrieves a paginated list of games, with optional player and turn filters.

  • HTTP Method: GET
  • Route: /api/games
  • Query Parameters:
    • player_id (UUID, optional): Filter games played by a specific player (either as White or Black).
    • min_turns (integer, optional): Filter games containing at least this number of turns.
    • color (w | b, optional): The focal player’s colour. Requires player_id (returns 400 otherwise).
    • opponent_type (human | bot, optional): Opponent type (relative to player_id).
    • opponent_id (UUID, optional): A specific opponent (relative to player_id).
    • stake (free | low | medium | high, optional): Stake tier on the pot (free = 0/null, low = 1–20, medium = 21–200, high = > 200).
    • limit (integer, default: 50, max: 200): Limit the number of games returned.
  • Success Response (200 OK):
    • Type: Array[GameSummary]
    • Example Payload:
      [
      {
      "id": "e0bb7d6c-48c9-4b67-bd1c-1bf501ea897a",
      "source": "local",
      "mode": "classic",
      "result": 1,
      "time_initial_sec": 60,
      "time_increment_sec": 2,
      "initial_stake_amount": 200,
      "final_stake_amount": 400,
      "white_money_delta": 400.0,
      "black_money_delta": -400.0,
      "stake_currency": "GOLD",
      "total_turns": 16,
      "started_at": "2026-06-06T12:30:00Z",
      "white_player": {
      "id": "d13cb5fa-5f90-449e-b9ef-0a563abde12a",
      "username": "Anonymous",
      "player_type": "human",
      "rating_classic": null
      },
      "black_player": {
      "id": "c88f98ec-7bf5-45cd-a9bb-5d18ea3abfe1",
      "username": "Bot (Greedy)",
      "player_type": "bot",
      "rating_classic": null
      }
      }
      ]

Retrieves full details of a specific game by its UUID, including all turns and board positions.

  • HTTP Method: GET
  • Route: /api/games/{game_id}
  • Path Parameters:
    • game_id (UUID, required): The unique identifier of the game.
  • Success Response (200 OK):
    • Type: GameDetail
    • Example Payload:
      {
      "id": "e0bb7d6c-48c9-4b67-bd1c-1bf501ea897a",
      "source": "local",
      "mode": "classic",
      "result": 1,
      "time_initial_sec": 60,
      "time_increment_sec": 2,
      "initial_stake_amount": 200,
      "final_stake_amount": 400,
      "white_money_delta": 400.0,
      "black_money_delta": -400.0,
      "stake_currency": "GOLD",
      "total_turns": 2,
      "started_at": "2026-06-06T12:30:00Z",
      "metadata_json": {},
      "white_player": {
      "id": "d13cb5fa-5f90-449e-b9ef-0a563abde12a",
      "username": "Anonymous",
      "player_type": "human",
      "rating_classic": null
      },
      "black_player": {
      "id": "c88f98ec-7bf5-45cd-a9bb-5d18ea3abfe1",
      "username": "Bot (Greedy)",
      "player_type": "bot",
      "rating_classic": null
      },
      "turns": [
      {
      "turn_number": 1,
      "active_color": "w",
      "dice_sorted": "125",
      "played_moves": ["e2e4", "g1f3"],
      "thinking_time_ms": 3500,
      "position_fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
      }
      ]
      }
  • Error Response (404 Not Found): If the game with the given UUID does not exist.

Retrieves a list of players matching the optional username search criteria.

  • HTTP Method: GET
  • Route: /api/players
  • Query Parameters:
    • username (string, optional): Substring filter on usernames (case-insensitive).
    • limit (integer, default: 50, max: 100): Limit the number of players returned.
  • Success Response (200 OK):
    • Type: Array[PlayerSummary]
    • Example Payload:
      [
      {
      "id": "d13cb5fa-5f90-449e-b9ef-0a563abde12a",
      "username": "Anonymous",
      "player_type": "human",
      "rating_classic": 1500
      }
      ]

Retrieves profile metadata for a specific player.

  • HTTP Method: GET
  • Route: /api/players/{player_id}
  • Path Parameters:
    • player_id (UUID, required): The unique identifier of the player.
  • Success Response (200 OK):
    • Type: PlayerSummary
    • Example Payload:
      {
      "id": "d13cb5fa-5f90-449e-b9ef-0a563abde12a",
      "username": "Anonymous",
      "player_type": "human",
      "rating_classic": 1500
      }
  • Error Response (404 Not Found): If the player with the given UUID does not exist.

Aggregate win/loss/draw statistics for a single player across all of their games. Outcomes are from the player’s perspective: a win is the player on the winning side (White with result = 1, Black with result = -1), mirrored for losses, with result = 0 a draw. Undecided games (result = null) are counted in games but excluded from the win-rate denominator. The win-rate convention matches /api/positions/equity: win_rate = (wins + 0.5·draws) / decided, where decided = wins + draws + losses (0.0 when nothing is decided).

  • HTTP Method: GET
  • Route: /api/players/{player_id}/stats
  • Path Parameters:
    • player_id (UUID, required): The unique identifier of the player.
  • Query Parameters (all optional; the counts and history bounds reflect the filter, while identity — username, player_type, rating_classic, rating_x2 — does not):
    • modeclassic | x2.
    • colorw | b (the focal player’s colour).
    • opponent_typehuman | bot.
    • opponent_id (UUID) — a specific opponent.
    • stake — tier free | low | medium | high on initial_stake_amount (the pot = 2× the site bet): free = 0/null, low = 1–20 (bet 1–10), medium = 21–200 (bet 25–100), high = > 200 (bet 300+).
    • date_from / date_to — start-date range, inclusive.
  • Success Response (200 OK):
    • Type: PlayerStats

    • Fields:

      • games — total games played, including undecided ones.
      • wins, draws, losses, decided — outcome counts from the player’s perspective; decided = wins + draws + losses.
      • win_rate(wins + 0.5·draws) / decided; 0.0 when no game is decided.
      • as_white, as_black — games played per colour.
      • first_game, last_gamestarted_at of the player’s earliest and latest games (null when the player has no games).
      • rating_classic, rating_x2 — rating snapshot from the player’s most recent game in each mode (null when the player has no game in that mode); a player carries an independent rating per mode.
    • Example Payload:

      {
      "id": "d13cb5fa-5f90-449e-b9ef-0a563abde12a",
      "username": "Anonymous",
      "player_type": "human",
      "games": 1284,
      "wins": 712,
      "draws": 23,
      "losses": 545,
      "decided": 1280,
      "win_rate": 0.5652,
      "as_white": 640,
      "as_black": 644,
      "first_game": "2024-01-03T18:22:00Z",
      "last_game": "2026-06-20T09:14:00Z",
      "rating_classic": 1500,
      "rating_x2": 1463
      }
    • An existing player with no games returns zeroed counts and null ratings/dates.

  • Error Response (404 Not Found): If the player with the given UUID does not exist.

Win-rate breakdowns for a player across categorical dimensions, plus the average number of moves — over the same filtered slice as the stats endpoint (the identity ratings are not part of this response).

  • HTTP Method: GET
  • Route: /api/players/{player_id}/breakdowns
  • Path Parameters:
    • player_id (UUID, required).
  • Query Parameters: identical to the stats endpoint (mode, color, opponent_type, opponent_id, stake, date_from, date_to).
  • Success Response (200 OK):
    • Type: PlayerBreakdowns

    • Fields:

      • by_color, by_mode, by_opponent_type, by_time_control — lists of { key, games, wins, draws, losses, win_rate } from the player’s perspective. key is w/b, classic/x2, human/bot, or — for time control — initSec:incSec (e.g. 60:1, formatted by the UI). win_rate = (wins + 0.5·draws)/decided.
      • avg_turns — mean total_turns over the filtered games (null when none match).
      • doubling — x2 cube offers, { player_offered: { accepted, declined }, opponent_offered: { accepted, declined } }. For each DOUBLE_OFFER event, its resolution is the next DOUBLE_ACCEPT/DOUBLE_DECLINE by sequence; the offerer’s colour vs the focal player’s decides which side it counts under (classic slices yield zeros).
    • Example Payload:

      {
      "by_color": [
      { "key": "w", "games": 26883, "wins": 16419, "draws": 228, "losses": 10236, "win_rate": 0.615 },
      { "key": "b", "games": 25443, "wins": 13482, "draws": 235, "losses": 11726, "win_rate": 0.535 }
      ],
      "by_mode": [
      { "key": "classic", "games": 32855, "wins": 19025, "draws": 359, "losses": 13471, "win_rate": 0.585 },
      { "key": "x2", "games": 19471, "wins": 10876, "draws": 104, "losses": 8491, "win_rate": 0.561 }
      ],
      "by_opponent_type": [
      { "key": "human", "games": 47886, "wins": 26877, "draws": 463, "losses": 20546, "win_rate": 0.566 },
      { "key": "bot", "games": 4440, "wins": 3024, "draws": 0, "losses": 1416, "win_rate": 0.681 }
      ],
      "by_time_control": [{ "key": "60:1", "games": 19471, "wins": 10876, "draws": 104, "losses": 8491, "win_rate": 0.561 }],
      "avg_turns": 16.2,
      "doubling": {
      "player_offered": { "accepted": 9205, "declined": 1761 },
      "opponent_offered": { "accepted": 5815, "declined": 1913 }
      }
      }
  • Error Response (404 Not Found): If the player with the given UUID does not exist.

A player’s rating over time — one point per active day (the rating after that day’s last game), per mode. Rating is a per-mode, point-in-time property, so this endpoint honours only mode and the date range (colour / opponent / stake do not shape a rating curve).

  • HTTP Method: GET

  • Route: /api/players/{player_id}/rating-history

  • Path Parameters:

    • player_id (UUID, required).
  • Query Parameters:

    • mode (classic | x2, optional): omit to get both series.
    • date_from / date_to: start-date range, inclusive.
  • Success Response (200 OK):

    • Type: RatingHistory{ classic: RatingPoint[], x2: RatingPoint[] }, each RatingPoint being { date, rating }, ordered by date. A series is empty when the player has no rated game in that mode (or it was filtered out); unrated games are excluded.

    • Example Payload:

      {
      "classic": [
      { "date": "2026-05-17", "rating": 3053 },
      { "date": "2026-05-30", "rating": 3127 },
      { "date": "2026-06-19", "rating": 3035 }
      ],
      "x2": [{ "date": "2026-05-15", "rating": 3119 }]
      }
  • Error Response (404 Not Found): If the player with the given UUID does not exist.

A player’s cumulative profit over time — one point per day with at least one paid game. Profit is cross-mode (a single currency denomination), so this endpoint honours only the date range: mode, colour, opponent, and stake do not shape a profit curve. Free games (money_delta = null) and beturanga.com games (always null) are excluded automatically.

  • HTTP Method: GET

  • Route: /api/players/{player_id}/profit-history

  • Path Parameters:

    • player_id (UUID, required).
  • Query Parameters:

    • date_from / date_to: game start-date range, inclusive. The cumulative resets to the window start (it is not all-time).
  • Success Response (200 OK):

    • Type: ProfitHistory{ points: ProfitPoint[] }, each ProfitPoint being { date, delta, cumulative }, ordered ascending by date. Empty when the player has no paid games in the window.

    • Fields per point:

      • date — the calendar day (UTC).
      • delta — net profit for that day; can be negative.
      • cumulative — running total from the window start up to and including this day.
    • Example Payload:

      {
      "points": [
      { "date": "2026-05-17", "delta": 150.00, "cumulative": 150.00 },
      { "date": "2026-05-18", "delta": -75.50, "cumulative": 74.50 },
      { "date": "2026-05-30", "delta": 200.00, "cumulative": 274.50 }
      ]
      }
  • Error Response (404 Not Found): If the player with the given UUID does not exist.


Read-only analytics over the deduplicated positions table and the turns that pass through them. Win rates are always from the side to move’s perspective, with draws counted as half a win: win_rate = (wins + 0.5·draws) / decided, where decided = wins + draws + losses. This is the cubeless-equity-equivalent win probability.

Returns the win probability for the side to move before any dice are rolled — aggregated over every turn played from the position, regardless of the roll. This is the metric for doubling-cube decisions: in a doubling game a player may offer to double the stake before rolling, so the number that matters is the pre-roll equity, not a per-roll win rate.

  • HTTP Method: GET
  • Route: /api/positions/equity
  • Query Parameters:
    • fen (string, required): Position FEN. Normalized server-side (move clocks ignored).
    • mode (string, optional): classic or x2. Omit for all modes.
  • Success Response (200 OK):
    • Type: PositionEquity

    • Example Payload:

      {
      "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -",
      "side_to_move": "w",
      "games": 154192,
      "wins": 83940,
      "draws": 1318,
      "losses": 68934,
      "win_rate": 0.5487
      }
    • games is the total matched turns; win_rate is computed over the decided games (wins + draws + losses). No-op self-loop turns are excluded (as in continuations). A position with no decided games returns zeros.

The pre-roll equity equals the per-roll continuation win rates averaged over all rolls, each weighted by that roll’s decided-game count — so it stays consistent with the continuations below.

Doubling guidance (side-to-move win probability):

Win probabilityRead
< 25%Behind — opponent is in their doubling window
25–60%Hold — no double yet
60–75%Doubling window — offer the double; opponent should take
> 75%Too good — opponent should drop

The opponent’s cubeless take point is 25%: they should accept a double while their own win probability exceeds it (i.e. while the doubler is below 75%).

Returns how players continued from a position after a specific roll, grouped by the resulting position (so permutations of the micro-moves collapse) and ranked by frequency.

  • HTTP Method: GET
  • Route: /api/positions/continuations
  • Query Parameters:
    • fen (string, required): Starting position FEN (normalized server-side).
    • dice (string, required): Roll as sorted piece letters, e.g. BPQ (cased to the side to move server-side).
    • mode (string, optional): classic or x2. Omit for all modes.
    • limit (integer, default: 50, max: 200): Max continuations returned (total_games stays the pre-limit total).
  • Success Response (200 OK):
    • Type: PositionContinuations

    • Example Payload:

      {
      "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -",
      "dice": "BPQ",
      "total_games": 4254,
      "items": [
      {
      "fen": "rnbqk1nr/pppp1ppp/8/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq -",
      "moves": ["e2e4", "d1f3", "f1c4"],
      "games": 3680,
      "wins": 2520,
      "draws": 68,
      "losses": 1092,
      "win_rate": 0.694
      }
      ]
      }
    • An empty moves list is a legal pass (the roll hit only pieces that cannot move). No-op turns (rolled but never played) are excluded server-side.