Skip to content

Tournament

Try the interactive example ↓

Bracket competitions from registration to a crowned champion: four formats (single elimination, double elimination, round robin, swiss), participant self-registration, generated brackets with byes, player-reported scores, automatic round progression, and live standings.

When to reach for it: game tournaments, playoff events, community competitions, hackathon brackets - any fixed field of entrants that plays down to a winner.

When not to: a continuously updated ranking over an open-ended metric belongs to Leaderboard; pairing players of similar skill into ad-hoc matches belongs to Matchmaking.

Concepts

  • Lifecycle - draft -> in_progress -> completed. Registration is open only before start, start generates the bracket and cannot be undone, and the tournament completes itself when the last match resolves.
  • Formats - single_elimination, double_elimination, round_robin (every pairing generated up front), swiss (a fixed number of rounds, each paired by score once the previous round finishes). format serializes as a plain string except swiss, which is {"swiss": {"rounds": N}}.
  • Participants are the callers - register enrolls the JWT subject; there is no flag to enter someone else, and each user can register once per tournament. Matches and standings reference participant IDs, not user IDs.
  • Matches carry round_number and match_number. Reporting a result fills or creates the next round automatically; a match becomes ready once both slots are occupied. Double elimination numbers its losers bracket from round 1001.
  • Scores are reported by the players - the caller must be one of the match's two participants (either one, loser included), the declared winner must be in that match, and a match is reported exactly once.
  • Byes - with an odd field, the leftover participant's match is stored already completed with score "Bye" and counts as a win.

Enterprise access

Tournaments are private owned resources by default. Creation accepts --workspace-id and --visibility private|workspace; the shared access API uses tournaments with reader/editor/manager grants. Participants, matches, standings, lists and profile includes inherit the tournament's read boundary.

Readers may register themselves while registration is open. Editors may start or complete the tournament. A shared editor still cannot report a match unless they are one of its actual participants. Reporting also checks that the match belongs to the tournament in the URL. Membership, workflow state and results cannot be forged through an ordinary scoped database client.

Seeding from a leaderboard requires independent read access to that namespace, checked at creation and again at start. Sharing a tournament does not share its seeding source. Bracket formats, capacity checks, byes, score validation and progression retain their existing business rules.

Create and register

snug tournament create --name "docs4-cup" --format single_elimination --max-participants 4
snug tournament register <tournament-id>       # enrolls the calling user
snug tournament participants <tournament-id>
snug tournament info <tournament-id>

Over HTTP, creation is POST /api/v1/tournaments with tournament_type and an optional config object - the only way to set custom swiss rounds (the CLI's --format swiss defaults to 6):

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "docs4-http-open", "tournament_type": "swiss",
       "config": {"swiss_rounds": 3, "max_participants": 8}}' \
  http://localhost:4000/api/v1/tournaments
{
  "status": 201,
  "msg": "Created",
  "data": {
    "id": "eqdNhpaHdcJADHpEQsDF",
    "name": "docs4-http-open",
    "format": { "swiss": { "rounds": 3 } },
    "status": "draft",
    "max_participants": 8,
    "winner_id": null,
    ...
  }
}

Registration refusals: a second register by the same user is 409 unique_constraint_violation, registering at capacity is 400 tournament_full, and registering after start is 400 registration_closed. An unknown tournament_type at creation is 400 invalid_format.

Start and the bracket

snug tournament start <tournament-id>
snug tournament matches <tournament-id>

start flips the tournament to in_progress and returns the opening round - for a 4-player single elimination, two pending matches. Starting again (or after completion) is 409 tournament_already_started; starting with zero participants is 400 insufficient_participants. Verified with three players: match 2 came back already completed with score "Bye" and its lone participant advanced. Participants carry a seed_rank that bracket generation would sort by, but no endpoint sets it today - everyone registers unseeded, so round 1 pairing simply follows the participant listing.

GET /api/v1/tournaments/{id}?include=participants,matches embeds both rosters in one response.

Report scores

snug tournament report-score <tournament-id> <match-id> \
  --winner <participant-id> --score "2-1"

--winner is a participant ID, --score a freeform string (max 100 chars). Refusals:

  • Caller not in the match: 403 unauthorized_reporter.
  • --winner not in the match: 400 winner_not_in_match (checked before the caller's own authorization).
  • Match already reported: 409 match_already_completed - results are final, there is no re-report or dispute endpoint.
  • Tournament not in_progress: 400 tournament_not_in_progress.

Progression is automatic: after both round-1 reports in the verified 4-player single elimination, matches showed a round-2 final in status ready holding the two winners. In swiss, only round 1 exists at start; completing it generated round 2 paired by score - winners against winners.

Standings and finishing

snug tournament standings <tournament-id>
snug tournament complete <tournament-id>

Standings rank by wins; captured mid-tournament:

{
  "standings": [
    { "rank": 1, "participant_id": "jXuBQ...", "user_id": "docs4-dave",
      "wins": 1, "losses": 0, "points": 3.0 },
    ...
  ],
  "tournament_id": "aAJrExyTZuFxjueAZtWJ",
  "page": { "has_more": false }
}

Standings, participants, and matches are lists: page them with --limit and --cursor; participants filter on user_id, checked_in, eliminated, and their record, matches on round_number, status, the participants, and winner_id.

points changes meaning by format: 3 per win in elimination and round robin, but in swiss it is the Buchholz tiebreaker (sum of your opponents' wins) - a swiss winner can show fewer points than the players they beat.

Reporting the last match finishes the tournament on its own: the verified final report flipped status to completed and set winner_id with no further call. complete exists to end a tournament early - it is idempotent on a completed tournament, and in swiss and round robin it crowns the current standings leader (verified after swiss round 1 of 3). In elimination formats an early complete takes the winner of the latest-round match, so with an unplayed final it records winner_id: null - report all matches instead.

Finding tournaments

GET /api/v1/tournaments takes the shared list parameters: q, filter on format, status, start_time, winner_id, created_at and updated_at, and sort by name, start_time or created_at (the default, newest first), as in ?q=docs4&filter[status][eq]=completed. The CLI's tournament list takes the same options (--filter 'status[eq]=completed'). There is no delete endpoint - once created, a tournament stays.

Limits and configuration

Tournaments accept up to 1024 participants by default, tunable via TOURNAMENT_MAX_PARTICIPANTS_PER_TOURNAMENT. Per-tournament caps below that are set at creation with --max-participants.

Reference

Interactive example

Create a tournament, report match results, and advance winners through its bracket. The service tracks progression between rounds.

An illustrative example that runs in your browser. No account or API calls required.