Tournaments

What wraps around the games: create, register, rounds, standings, completion. Each paired game itself — gate, moves, result — plays exactly as in the previous chapters, just with tournamentId set.

The whole flow

  1. Platform→EnginePOST /v1/tournaments (format, game type, config)req
  2. Client→PlatformUsers register in your lobby UI
  3. Platform→EnginePOST /v1/tournaments/{id}/join — per user, rating in the bodyreq
  4. Platform→EnginePOST /v1/tournaments/{id}/start — close registrationreq
  5. Engine→EngineFormat strategy generates round pairings (busy-checked; bye if odd)
  6. Engine→Platformmatch_found per pair (tournamentId set) — notify absenteescb
  7. Client→EngineConnected entrants get game_state pushed → playws
  8. Engine→Platformgame_result per game → standings updatecb
  9. Engine→EngineRound done → next pairings · arena: re-pair free players until time up
  10. Engine→Platformtournament_complete (final standings)cb

Phase by phase

  1. 1

    Create

    Platform → Engine · POST /v1/tournaments

    1. 🏗️platform callsPOST /v1/tournaments
    2. ⚙️engine creates the tournament
    3. 🆔returnstournamentId
    FieldTypeRequiredDefaultDescription
    formatstringyes—arena · swiss · round_robin · knockout
    variantstring—"standard"one of the variants from GET /v1/game-types
    timeControlstringyes—a time-control id from GET /v1/game-types, e.g. "blitz_3_2"
    ratedboolean—truewhether tournament games update Glicko-2
    roundsnumberif swissceil(log2(n))number of Swiss rounds
    roundDeadlineSecnumber—0per-round deadline in seconds; 0 = no deadline
    allowMatchmakingDuringTournamentboolean—falsefalse = participants can't queue matchmaking while entered (exclusive)
    arenaobjectif arena—{ durationMin } — arena duration in minutes, capped 1..1440
    http
    POST /v1/tournaments
    X-Service-Auth: <secret>
    
    {
      "format": "arena",                          // arena | swiss | round_robin | knockout
      "variant": "standard",
      "timeControl": "blitz_3_2",
      "rated": true,
      "roundDeadlineSec": 0,
      "allowMatchmakingDuringTournament": false,
      "arena": { "durationMin": 60 },             // arena only
      "rounds": 7                                 // swiss only
    }
    
    # 201
    { "success": true, "response": "success", "responseCode": 201,
      "data": { "tournamentId": "trn_44" } }
    # Unsupported format → 422. The presence gate for tournament games is set by
    # the engine (grace_period), not by this request.
  2. 2

    Registration

    Platform → Engine · /join · /withdraw · GET /v1/tournaments

    1. 📝User registers in the lobby
    2. ➕platform callsPOST /tournaments/{id}/join (rating in body)
    3. 📋engine adds them to the field

    Tournament join carries the rating in the body — unlike matchmaking — because it is a server-to-server call the client never touches. Entering is exclusive by default: while entered, a queue message is rejected.

    FieldTypeRequiredDefaultDescription
    userIdstringyes—participant
    ratingobjectif rated—{ r, rd, vol } for the tournament's (variant, speed) — no floor / lastGameAt here
    http
    POST /v1/tournaments/{id}/join
    { "userId": "user_123", "rating": { "r": 1512.3, "rd": 84.1, "vol": 0.0598 } }
    
    # 200 { data: null } · 409 if the user is already in an active tournament
    
    # withdraw and start take the same shape and also reply 200 { data: null }:
    POST /v1/tournaments/{id}/withdraw   { "userId": "user_123" }   // forfeits remaining games
    POST /v1/tournaments/{id}/start                                 // close registration + begin
    
    # GET /v1/tournaments — lobby list: tournaments still open for registration
    { "tournaments": [ { "tournamentId": "trn_44", "format": "arena", "variant": "standard",
        "timeControl": "blitz_3_2", "rated": true, "players": 12, "durationMin": 60 } ] }
  3. 3

    Start & pairing

    Platform → Engine (/start) · then Engine (internal)

    1. ▶️platform callsPOST /tournaments/{id}/start
    2. 🧮format strategy pairs entrants (bye if odd)
    3. 🏠one room per pair

    /start closes registration. The format strategy (arena · swiss · round_robin · knockout) generates pairings, verifies each player against the busy-set, and creates a room per pair. Odd count → one player gets a bye.

  4. 4

    Rounds — play each game

    Engine → Client (socket) · Engine → Platform (callback)

    1. 📡match_found per pair (tournamentId set)
    2. 📨engine POSTs webhook → notify absentees
    3. 📥connected entrants get game_state → play
    jsonc
    {
      "eventId": "evt_101",
      "type": "match_found",
      "matchId": "match_t1a",
      "variant": "standard",
      "timeControl": "blitz_3_2",
      "tournamentId": "trn_44",            // ← set (null for plain matchmaking)
      "timeControlDetail": { "base": 180, "inc": 2 },
      "players": [
        { "userId": "user_123", "username": "magnus_fan", "color": "white" },
        { "userId": "user_456", "username": "the_turk", "color": "black" }
      ]
    }
    // This is the one case where the match_found CALLBACK does real work: an
    // entrant may be OFFLINE when their round opens (they joined over REST, not
    // from a socket) — notify them out of band. The room waits at the grace_period
    // gate; when they log in and connect, the engine finds it by userId and pushes
    // the game state.
    // Optional round hint (own callback, safe to ignore):
    { "eventId": "evt_100", "type": "tournament_round_started", "tournamentId": "trn_44", "round": 3 }
  5. 5

    Results & standings

    Engine → Platform (callback) · GET /v1/tournaments/{id}

    1. 🏁each game → game_result → standings update
    2. 📥platform pollsGET /v1/tournaments/{id}
    3. 🔁round done → next pairings · arena re-pairs free players

    Every finished game sends its own game_result (with rating delta when rated) and updates the standings. Round-based formats wait for the whole round before pairing the next; arena re-pairs a player as soon as they are free, until time runs out.

    http
    GET /v1/tournaments/{id}
    # 200 — config + standings + current round
    {
      "success": true, "response": "success", "responseCode": 200,
      "data": {
        "standings": [
          { "userId": "user_456", "rank": 1, "score": 21, "games": 14 },
          { "userId": "user_123", "rank": 2, "score": 19, "games": 14 }
        ]
      }
    }
  6. ✓

    Complete

    Engine → Platform (callback)

    1. 🧾no games left → final standings
    2. 📨engine POSTstournament_complete (retries forever)
    3. ✅platform saves standings

    When no games remain, the engine sends tournament_complete — like game_result, it retries indefinitely: no tournament outcome is ever lost.

    json
    {
      "eventId": "evt_004",
      "type": "tournament_complete",
      "tournamentId": "trn_44",
      "standings": [
        { "userId": "user_456", "rank": 1, "score": 21, "games": 14 },
        { "userId": "user_123", "rank": 2, "score": 19, "games": 14 }
      ]
    }

What can go wrong

SituationBehaviour & handling
409 on /joinThe user is already in an active tournament — one at a time. Cleared on finish or withdraw.
Entrant can't queue matchmakingBy design: tournaments are exclusive by default (queue is rejected with already_in_game). Set allowMatchmakingDuringTournament:true at create time to permit it.
Entrant offline when the round opensExpected — that's what grace_period is for. Use the match_found callback to notify them; the room waits. Never arriving by the deadline forfeits that game (the engine does not requeue, unlike matchmaking).
Entrant offline between roundsFine. Tournament membership is independent of the session — the session reaper never touches membership or standings.
Withdraw mid-tournamentPOST /withdraw forfeits the player's remaining games and frees their membership.