What the system does
Players register with Discord, play matches anywhere, and see themselves climb. Results flow in from four sources into one append-only match ledger; the ledger drives two Elo ratings per player per season and an achievement layer.
Per-format Elo + general season Elo. Both Elo, both reset each season.
Direct pickup, in-tool tournament, Challonge import, Melee import.
Automatic via criteria engine, or manual with player proof and admin grant.
Result ingestion
Every result ends up in the same ledgerClean architecture, four layers
Dependencies point inward only. The domain has zero framework imports, use cases depend on port interfaces, and adapters implement those ports. Swapping Challonge for another platform, or SQLAlchemy for another ORM, never touches the domain.
Layer diagram
dependency direction points downward, inwardPorts
interfaces the domain and use cases depend onRepositories
- MatchRepository
- PlayerRepository
- RatingRepository
- TournamentRepository
- AchievementRepository
- DecklistRepository
- SeasonRepository
- FormatRepository
- ImportQueueRepository
External
- TournamentSource
- CardProvider
- IdentityProvider
- SecretStore
Infra
- Clock
- IdGenerator
- EventBus
Why it matters here
where the dependency rule pays off-
TournamentSourceChallonge, Melee and internal tournaments are interchangeable. Melee's API is gated to approved organizations, so
MeleeSourcecan ship later without touching a single use case. BYO credentials are read throughSecretStore, never hard-coded. -
EloEnginePure domain service. Placement-based and kill-based multiplayer scoring are strategies selected by the season's
multiplayer_scoringconfig. Unit-testable with no database. -
IdentityMatcherUsername normalization and matching for imported participants lives here, testable in isolation from the provider clients.
-
AchievementCriteriaFixed vocabulary of criteria types evaluated against match fixtures in tests. Manual awards bypass it entirely and go to the admin review queue.
Entity relationship diagram
Fifteen tables. The spine is Match → RatingChange:
every Elo movement is traceable to the result that caused it. Every rating row is
scoped to a Season and resets when a new season opens.
ERD
PK = primary key · FK = foreign key · 1—* = one-to-manyRelationships
1—* = one-to-many| Parent | Cardinality | Child |
|---|---|---|
| Player | 1—* | PlayerExternalAccount |
| Player | 1—* | MatchParticipant |
| Player | 1—* | Rating |
| Player | 1—* | PlayerAchievement |
| Player | 1—* | Decklist |
| Season | 1—* | Match · Tournament · Rating |
| Format | 1—* | Match · Tournament · Decklist |
| Tournament | 1—* | Match · Decklist · ImportQueueItem |
| Match | 1—* | MatchParticipant · MatchElimination |
| Match | 1—* | RatingChange |
| Decklist | 1—* | DecklistEntry |
| Achievement | 1—* | PlayerAchievement |
Model invariants
the rules the schema encodes-
Ratings are season-scopedEvery
Ratingrow carries aseason_id. Both the per-format and the general Elo reset to the baseline (1500) when a new season opens. There is no cross-season skill carry-over. -
General Elo is format_id = NULLOne row per (player, season) with
format_idnull is the general season rating, fed by every match. A row withformat_idset is that format's rating within that season. -
Every Elo move is auditable
RatingChangeis append-only and points at theMatchthat caused it.POST /admin/ratings/recomputereplays the ledger and rebuilds both tables. -
multiplayer_scoring is immutable after activationPlacement pods write
MatchParticipant.placement. Kill pods writeMatchEliminationrows, one per pairwise Elo exchange. The engine reads the season config to decide which to use. -
External username is the identity join keyImported participants resolve through
PlayerExternalAccount.external_username. Unresolved ones park inImportQueueItemand their matches hold atpending, blocked from rating application until mapped.
API surface
Base path /api/v1, JSON, bearer JWT issued by the Discord
OAuth exchange. The full machine-readable contract lives in
openapi.yaml (OpenAPI 3.1) and is
generated directly from the FastAPI route definitions, so code and contract cannot drift.
Across 11 tag groups.
Discord OAuth code exchange, refresh token flow.
Emitted from FastAPI; consumed by the SPA and any future Discord bot.
Auth
Discord OAuth exchange, token refresh, current player.
POST /auth/discord Exchange a Discord OAuth code for an API token
Public. Accepts the authorization code from the Discord OAuth redirect.
Creates the Player on first login and records the Discord
PlayerExternalAccount link automatically.
Request body required
| Field | Type | Description |
|---|---|---|
| code * | string | Authorization code from Discord. |
| redirect_uri * | string | Must match the redirect URI registered with Discord. |
| code_verifier | string | PKCE verifier, if PKCE was used. |
Responses
200Token pair and the authenticated player.| Field | Type | Description |
|---|---|---|
| access_token * | string | |
| refresh_token * | string | |
| expires_in * | integer | Access token lifetime in seconds. |
| player * | Player |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
401Missing or invalid token.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /auth/refresh Refresh an access token
Refresh an access token
Request body required
| Field | Type | Description |
|---|---|---|
| refresh_token * | string |
Responses
200New token pair.| Field | Type | Description |
|---|---|---|
| access_token * | string | |
| refresh_token * | string | |
| expires_in * | integer |
401Missing or invalid token.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
GET /auth/me Get the authenticated player
Get the authenticated player
Responses
200The current player, with admin flag.| Field | Type | Description |
|---|---|---|
| id * | string | |
| display_name * | string | |
| avatar_url | string | |
| string | Only returned for the authenticated player. | |
| is_admin * | boolean | |
| created_at * | string |
401Missing or invalid token.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Players
Public player profiles and self-service account settings.
GET /players List or search players
List or search players
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| q | query | string | Case-insensitive substring match on display name. |
| season_id | query | string | Restrict to players with a rating in this season. |
| limit | query | integer | |
| offset | query | integer |
Responses
200Page of players.| Field | Type | Description |
|---|---|---|
| data | array of Player | |
| meta | PageMeta |
GET /players/{id} Get a player profile
Returns the profile with ratings, recent matches, and unlocked achievements.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Player id. |
Responses
200Full player profile.| Field | Type | Description |
|---|---|---|
| player * | Player | |
| ratings * | array of Rating | |
| achievements * | array of PlayerAchievement | |
| recent_matches | array of Match | |
| linked_accounts | array of PlayerExternalAccount |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
PATCH /players/me Update own profile
Update own profile
Request body required
| Field | Type | Description |
|---|---|---|
| display_name | string | |
| avatar_url | string |
Responses
200Updated player.| Field | Type | Description |
|---|---|---|
| id * | string | |
| display_name * | string | |
| avatar_url | string | |
| string | Only returned for the authenticated player. | |
| is_admin * | boolean | |
| created_at * | string |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
401Missing or invalid token.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Accounts
Linking external identities (Discord, Melee, Challonge).
GET /players/me/accounts List linked external accounts
Returns every PlayerExternalAccount for the caller. A provider may be
linked without an API-verified id; in that case external_user_id is
null and external_username is a manually declared handle used for
identity resolution on import.
Responses
200Linked accounts.| Field | Type | Description |
|---|---|---|
| data | array of PlayerExternalAccount |
401Missing or invalid token.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /players/me/accounts Link an external account
Link a Discord, Melee, or Challonge identity. Discord is normally linked as a side effect of login and is idempotent. Melee has no public profile API, so a username-only link is the expected shape. Challonge accepts either an OAuth-verified user id or a username-only link.
Request body required
| Field | Type | Description |
|---|---|---|
| provider * | enum: "discord", "melee", "challonge" | |
| external_user_id | string | Platform-assigned id, when known or verified via OAuth. |
| external_username | string | Human handle. Used for identity resolution on import. |
Responses
201Account link created.| Field | Type | Description |
|---|---|---|
| id * | string | |
| provider * | enum: "discord", "melee", "challonge" | |
| external_user_id | string | Platform-assigned id, when known or OAuth-verified. |
| external_username | string | Human handle. Used as the identity join key on import when no id is available. |
| verified * | boolean | True when the link was established through the provider's OAuth flow. |
| created_at | string |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Resource already exists or state conflict.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
DELETE /players/me/accounts/{id} Unlink an external account
Unlink an external account
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Responses
204Unlinked. Future imports will no longer resolve to this player via that handle.404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Formats
Admin-defined play formats.
GET /formats List formats
List formats
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| active_only | query | boolean |
Responses
200Formats.| Field | Type | Description |
|---|---|---|
| data | array of Format |
POST /formats Create a format
Admin only. admin
Request body required
| Field | Type | Description |
|---|---|---|
| name * | string | |
| slug | string | Derived from name when omitted. |
| is_active | boolean |
Responses
201Created.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| slug * | string | |
| is_active * | boolean |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Resource already exists or state conflict.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
PATCH /formats/{id} Update a format
Admin only. admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Format id. |
Request body required
| Field | Type | Description |
|---|---|---|
| name | string | |
| is_active | boolean |
Responses
200Updated.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| slug * | string | |
| is_active * | boolean |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Seasons
Admin-defined competitive seasons and multiplayer scoring rules.
GET /seasons List seasons
List seasons
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| status | query | enum: "draft", "active", "closed" |
Responses
200Seasons, newest first.| Field | Type | Description |
|---|---|---|
| data | array of Season |
POST /seasons Create a season
Admin only. multiplayer_scoring is required and immutable once the
season moves to active; pick it deliberately.
admin
Request body required
| Field | Type | Description |
|---|---|---|
| name * | string | |
| slug | string | |
| starts_at * | string | |
| ends_at | string | |
| multiplayer_scoring * | enum: "placement", "kill" | How Commander / multiplayer pods are scored for Elo this season. |
Responses
201Created.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| slug * | string | |
| status * | enum: "draft", "active", "closed" | |
| multiplayer_scoring * | enum: "placement", "kill" | How multiplayer pods are scored for Elo this season. Immutable once
the season is active.
|
| starts_at * | string | |
| ends_at | string |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
PATCH /seasons/{id} Update a season
Admin only. multiplayer_scoring may only be changed while the season is
still draft. Changing it after activation returns 409.
admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Request body required
| Field | Type | Description |
|---|---|---|
| name | string | |
| ends_at | string | |
| status | enum: "draft", "active", "closed" | |
| multiplayer_scoring | enum: "placement", "kill" |
Responses
200Updated.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| slug * | string | |
| status * | enum: "draft", "active", "closed" | |
| multiplayer_scoring * | enum: "placement", "kill" | How multiplayer pods are scored for Elo this season. Immutable once
the season is active.
|
| starts_at * | string | |
| ends_at | string |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Attempted to change multiplayer_scoring on a non-draft season.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Matches
Pickup submissions, confirmations, disputes, and pod elimination reports.
POST /matches Submit a pickup match
Player A submits a result. The match is created with status pending and
does not touch any rating until the opponent confirms.
Request body required
| Field | Type | Description |
|---|---|---|
| format_id * | string | |
| season_id * | string | |
| kind * | enum: "duel", "pod" | duel is 1v1. pod is multiplayer; supply placement on
participants when the season is placement-scored, or submit
eliminations separately via POST /matches/{id}/eliminations
when it is kill-scored.
|
| played_at | string | Defaults to now. |
| participants * | array of object |
Responses
201Match created in pending state.| Field | Type | Description |
|---|---|---|
| id * | string | |
| season_id * | string | |
| format_id * | string | |
| tournament_id | string | Null for pickup matches. |
| source * | enum: "direct", "internal", "challonge", "melee" | |
| source_ref | string | Match id on the source platform, when imported. |
| kind * | enum: "duel", "pod" | |
| status * | enum: "pending", "confirmed", "applied", "disputed" | pending — submitted, awaiting opponent confirmation or import mapping.
confirmed — accepted but not yet applied.
applied — ratings updated.
disputed — under review.
|
| played_at | string | |
| created_at | string | |
| participants * | array of MatchParticipant |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Unknown player, format, or season.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
GET /matches List matches
List matches
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| season_id | query | string | |
| format_id | query | string | |
| tournament_id | query | string | |
| player_id | query | string | |
| status | query | enum: "pending", "confirmed", "applied", "disputed" | |
| source | query | enum: "direct", "internal", "challonge", "melee" | |
| limit | query | integer | |
| offset | query | integer |
Responses
200Page of matches.| Field | Type | Description |
|---|---|---|
| data | array of Match | |
| meta | PageMeta |
GET /matches/{id} Get a match
Get a match
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Match id. |
Responses
200Match with participants and eliminations.| Field | Type | Description |
|---|---|---|
| id * | string | |
| season_id * | string | |
| format_id * | string | |
| tournament_id | string | Null for pickup matches. |
| source * | enum: "direct", "internal", "challonge", "melee" | |
| source_ref | string | Match id on the source platform, when imported. |
| kind * | enum: "duel", "pod" | |
| status * | enum: "pending", "confirmed", "applied", "disputed" | pending — submitted, awaiting opponent confirmation or import mapping.
confirmed — accepted but not yet applied.
applied — ratings updated.
disputed — under review.
|
| played_at | string | |
| created_at | string | |
| participants * | array of MatchParticipant | |
| eliminations | array of MatchElimination | |
| rating_changes | array of RatingChange |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /matches/{id}/confirm Confirm a pickup match
Player B accepts the reported result. On confirmation the match moves to
applied, ratings update, and the response returns the resulting
RatingChange rows so the UI can show Elo deltas immediately.
Only a participant other than the submitter may confirm. A participant
may also confirm their own imported match to clear the pending state.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Match id. |
Responses
200Match applied, with rating deltas.| Field | Type | Description |
|---|---|---|
| match * | Match | |
| rating_changes * | array of RatingChange |
403Caller is not a participant, or is the original submitter.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Match is not in pending state.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /matches/{id}/dispute Dispute a match
Any participant may move a pending match to disputed. Admin review is required to resolve.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Match id. |
Request body
| Field | Type | Description |
|---|---|---|
| reason | string |
Responses
200Match moved to disputed.| Field | Type | Description |
|---|---|---|
| id * | string | |
| season_id * | string | |
| format_id * | string | |
| tournament_id | string | Null for pickup matches. |
| source * | enum: "direct", "internal", "challonge", "melee" | |
| source_ref | string | Match id on the source platform, when imported. |
| kind * | enum: "duel", "pod" | |
| status * | enum: "pending", "confirmed", "applied", "disputed" | pending — submitted, awaiting opponent confirmation or import mapping.
confirmed — accepted but not yet applied.
applied — ratings updated.
disputed — under review.
|
| played_at | string | |
| created_at | string | |
| participants * | array of MatchParticipant |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Match is not in pending state.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /matches/{id}/eliminations Report eliminations for a pod match
Player-reported kill data for kill-scored seasons. Each entry records who
eliminated whom and is stored as one pairwise comparison. Idempotent on
(match_id, killer, victim).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Match id. |
Request body required
| Field | Type | Description |
|---|---|---|
| eliminations * | array of object |
Responses
201Eliminations recorded.| Field | Type | Description |
|---|---|---|
| data | array of MatchElimination |
400Season is placement-scored, or killer/victim is not a participant.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Tournaments
In-tool tournaments (Swiss / Top 8) and imports from Challonge / Melee.
GET /tournaments List tournaments
List tournaments
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| season_id | query | string | |
| format_id | query | string | |
| source | query | enum: "internal", "challonge", "melee" | |
| status | query | enum: "draft", "registration", "active", "completed", "cancelled" | |
| limit | query | integer | |
| offset | query | integer |
Responses
200Page of tournaments.| Field | Type | Description |
|---|---|---|
| data | array of Tournament | |
| meta | PageMeta |
POST /tournaments Create an in-tool tournament
Admin only. Creates the tournament and its participant roster. admin
Request body required
| Field | Type | Description |
|---|---|---|
| name * | string | |
| format_id * | string | |
| season_id * | string | |
| structure * | enum: "swiss", "swiss_top8", "single_elim", "double_elim", "round_robin" | |
| starts_at * | string | |
| ends_at | string | |
| participant_ids | array of string | Optional pre-registration roster. |
Responses
201Created.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| format_id * | string | |
| season_id * | string | |
| source * | enum: "internal", "challonge", "melee" | |
| source_ref | string | Tournament id on the source platform, when imported. |
| structure * | enum: "swiss", "swiss_top8", "single_elim", "double_elim", "round_robin" | |
| status * | enum: "draft", "registration", "active", "completed", "cancelled" | |
| starts_at | string | |
| ends_at | string | |
| external_url | string | Link back to the source platform, when imported. |
| decklists_available | boolean | True when decklists can be attached. Melee imports and internal tournaments are true; Challonge imports are false. |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /tournaments/import Import a tournament from Challonge or Melee
Admin only. Pulls the tournament, its participants, and its matches from
the configured provider using the deployment's BYO credentials.
Participants are resolved to Player rows via PlayerExternalAccount.
Unmatched participants are parked in ImportQueueItem with
status: waiting and their matches do not apply to ratings until an
admin maps them via POST /admin/import-queue/{id}/map.
admin
Request body required
| Field | Type | Description |
|---|---|---|
| provider * | enum: "challonge", "melee" | |
| source_ref * | string | Tournament id or slug on the source platform. |
| season_id * | string | |
| format_id | string | Resolved from the source tournament when omitted. |
| import_decklists | boolean | Pull decklists when the provider exposes them (Melee does; Challonge does not). |
Responses
201Import queued/processed.| Field | Type | Description |
|---|---|---|
| tournament * | Tournament | |
| import_queue_items * | array of ImportQueueItem | One per unresolved participant. Empty when everything matched. |
502Upstream provider rejected the request or was unreachable.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Provider credentials not configured for this deployment.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
GET /tournaments/{id} Get a tournament
Includes standings, structure, source metadata, and decklist availability.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Tournament id. |
Responses
200Tournament detail.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| format_id * | string | |
| season_id * | string | |
| source * | enum: "internal", "challonge", "melee" | |
| source_ref | string | Tournament id on the source platform, when imported. |
| structure * | enum: "swiss", "swiss_top8", "single_elim", "double_elim", "round_robin" | |
| status * | enum: "draft", "registration", "active", "completed", "cancelled" | |
| starts_at | string | |
| ends_at | string | |
| external_url | string | Link back to the source platform, when imported. |
| decklists_available | boolean | True when decklists can be attached. Melee imports and internal tournaments are true; Challonge imports are false. |
| participants | array of TournamentParticipant | |
| rounds | array of Round | |
| standings | array of Standing |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /tournaments/{id}/rounds Generate the next round of pairings
Admin only. For swiss / swiss_top8 structures the domain
SwissPairing service computes pairings from current standings and
tiebreakers. For elimination structures the next bracket round is
generated from prior results.
admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Tournament id. |
Responses
200Generated round with pairings.| Field | Type | Description |
|---|---|---|
| id | string | |
| number | integer | |
| status | enum: "pending", "active", "complete" | |
| matches | array of Match |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Previous round has unreported matches.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Decklists
Per-tournament decklists with Moxfield-style graphic and list views.
GET /tournaments/{id}/decklists List decklists for a tournament
List decklists for a tournament
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Tournament id. |
Responses
200Decklist summaries (without entries).| Field | Type | Description |
|---|---|---|
| data | array of Decklist |
POST /tournaments/{id}/decklists Submit a decklist
A player submits (or replaces) their decklist for a tournament. Entries
reference Scryfall card ids; the CardProvider adapter resolves names,
sets, and image URLs for the Moxfield-style graphic view.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Tournament id. |
Request body required
| Field | Type | Description |
|---|---|---|
| name * | string | |
| format_id * | string | |
| entries * | array of object |
Responses
201Decklist created.| Field | Type | Description |
|---|---|---|
| id * | string | |
| tournament_id * | string | |
| player_id * | string | |
| format_id | string | |
| name * | string | |
| status * | enum: "draft", "submitted", "locked" | |
| submitted_at | string | |
| entry_count | integer |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller is not a participant of this tournament.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Tournament does not accept decklists (cancelled, or import source without decklist support).| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
GET /decklists/{id} Get a decklist with entries
Returns entries grouped by section plus resolved card metadata and image URLs, sufficient to render both the graphic (card grid) and list views.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Responses
200Full decklist.| Field | Type | Description |
|---|---|---|
| id * | string | |
| tournament_id * | string | |
| player_id * | string | |
| format_id | string | |
| name * | string | |
| status * | enum: "draft", "submitted", "locked" | |
| submitted_at | string | |
| entry_count | integer | |
| entries | array of DecklistEntry | |
| sections | object | Entry ids grouped by section, for the graphic view grouping. |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
PATCH /decklists/{id} Update a decklist
The owner may edit while the decklist is draft or submitted. Admins may edit in any state.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Request body required
| Field | Type | Description |
|---|---|---|
| name | string | |
| status | enum: "draft", "submitted", "locked" | |
| entries | array of object |
Responses
200Updated.| Field | Type | Description |
|---|---|---|
| id * | string | |
| tournament_id * | string | |
| player_id * | string | |
| format_id | string | |
| name * | string | |
| status * | enum: "draft", "submitted", "locked" | |
| submitted_at | string | |
| entry_count | integer |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Decklist is locked and caller is not an admin.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Rankings
Format and general season leaderboards.
GET /rankings Get the leaderboard
Returns Elo standings for a season. Omit format_id for the general
season ranking (all matches); set it for a format-specific ranking.
Both boards are Elo and both reset when a new season opens. Ranks are
dense-ordered by value descending.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| season_id * | query | string | |
| format_id | query | string | Omit for the general season ranking. |
| limit | query | integer | |
| offset | query | integer |
Responses
200Ranked standings.| Field | Type | Description |
|---|---|---|
| season_id * | string | |
| scope * | enum: "general", "format" | general when format_id was omitted, otherwise format. |
| format_id | string | Present only when scope is format. |
| format_name | string | |
| data * | array of RankingEntry | |
| meta | PageMeta |
404Unknown season or format.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Achievements
Achievement definitions and awards (automatic and manual).
GET /achievements List achievement definitions
List achievement definitions
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| active_only | query | boolean | |
| award_method | query | enum: "automatic", "manual" |
Responses
200Achievement definitions.| Field | Type | Description |
|---|---|---|
| data | array of Achievement |
POST /achievements Create an achievement
Admin only. admin
Request body required
| Field | Type | Description |
|---|---|---|
| name * | string | |
| description | string | Human-readable criteria. Free text. |
| reward_text | string | Free-text reward description. Not enforced or delivered by the system at this stage. |
| award_method * | enum: "automatic", "manual" | |
| criteria | AchievementCriteria | |
| is_active | boolean |
Responses
201Created.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| description | string | Human-readable criteria text. |
| reward_text | string | Free-text reward. Not enforced or delivered by the system. |
| award_method * | enum: "automatic", "manual" | |
| criteria | AchievementCriteria | |
| is_active * | boolean |
400automatic requires criteria; manual must not set it.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
PATCH /achievements/{id} Update an achievement
Admin only. admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Request body required
| Field | Type | Description |
|---|---|---|
| name | string | |
| description | string | |
| reward_text | string | |
| criteria | AchievementCriteria | |
| is_active | boolean |
Responses
200Updated.| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| description | string | Human-readable criteria text. |
| reward_text | string | Free-text reward. Not enforced or delivered by the system. |
| award_method * | enum: "automatic", "manual" | |
| criteria | AchievementCriteria | |
| is_active * | boolean |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /achievements/{id}/request Request a manual achievement
Player submits proof for a manual achievement. Creates a
PlayerAchievement in pending_review. Admins resolve it via
POST /player-achievements/{id}/review.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Request body required
| Field | Type | Description |
|---|---|---|
| proof_text * | string | |
| proof_url | string |
Responses
201Request submitted.| Field | Type | Description |
|---|---|---|
| id * | string | |
| player_id * | string | |
| achievement_id * | string | |
| status * | enum: "pending_review", "granted", "denied" | Automatic awards are created directly as granted. |
| proof_text | string | Manual submissions only. |
| proof_url | string | |
| awarded_at | string | Null while pending_review. |
| awarded_by | string | Admin who granted a manual award. Null for automatic awards. |
400Achievement is not manual, or already requested.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /player-achievements/{id}/review Grant or deny a manual achievement
Admin only. admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Request body required
| Field | Type | Description |
|---|---|---|
| decision * | enum: "granted", "denied" | |
| note | string |
Responses
200Player achievement updated.| Field | Type | Description |
|---|---|---|
| id * | string | |
| player_id * | string | |
| achievement_id * | string | |
| status * | enum: "pending_review", "granted", "denied" | Automatic awards are created directly as granted. |
| proof_text | string | Manual submissions only. |
| proof_url | string | |
| awarded_at | string | Null while pending_review. |
| awarded_by | string | Admin who granted a manual award. Null for automatic awards. |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Not in pending_review.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Admin
Integration credentials, orphaned import mapping, rating repairs.
GET /admin/integrations List integration configurations
Admin only. Credential material is never included; the response reports only whether credentials are present and when they were last set. admin
Responses
200Integration status.| Field | Type | Description |
|---|---|---|
| data | array of IntegrationStatus |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
PUT /admin/integrations/{provider} Configure BYO credentials for an integration
Admin only. Stores the provider credentials encrypted at rest. Replaces
any existing credentials for the provider. Raw credential values are
write-only and never echoed back.
Access model differs by provider: Challonge issues standard API keys and
OAuth clients to any account. Melee restricts API access to organizations
and their designated staff and requires approval from
contact@melee.gg before issuing a client id and secret.
admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| provider * | path | enum: "challonge", "melee" |
Request body required
| Field | Type | Description |
|---|---|---|
| enabled * | boolean | |
| credentials * | object | Provider-specific key/value material, e.g. Challonge
api_key or client_id/client_secret, or Melee
client_id/client_secret/org_id. Stored encrypted.
|
Responses
200Stored. Credentials are not echoed.| Field | Type | Description |
|---|---|---|
| provider * | enum: "challonge", "melee" | |
| enabled * | boolean | |
| configured * | boolean | True when credential material is present. The material itself is never returned. |
| configured_at | string | |
| configured_by | string |
400Validation failed.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
GET /admin/import-queue List orphaned import participants
Admin only. Participants from imported tournaments that no PlayerExternalAccount matched. admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| status | query | enum: "waiting", "mapped" | |
| tournament_id | query | string |
Responses
200Queue items.| Field | Type | Description |
|---|---|---|
| data | array of ImportQueueItem |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /admin/import-queue/{id}/map Map an orphaned participant to a player
Admin only. Resolves the queue item to a Player and applies every
parked match for that participant, producing RatingChange rows.
Alternatively, a player linking the matching username in Account
settings will resolve the item automatically; this endpoint is the
manual override.
admin
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string |
Request body required
| Field | Type | Description |
|---|---|---|
| player_id * | string | |
| create_link | boolean | Also create the PlayerExternalAccount so future imports resolve automatically. |
Responses
200Mapped. Includes the rating changes applied from previously parked matches.| Field | Type | Description |
|---|---|---|
| import_queue_item * | ImportQueueItem | |
| rating_changes * | array of RatingChange |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
404Resource not found.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
409Already mapped.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
POST /admin/ratings/recompute Recompute ratings for a season
Admin only. Replays the match ledger in played_at / created_at order
and rebuilds Rating and RatingChange for the season. Used after
corrections to match data or a change in the domain EloEngine policy.
admin
Request body required
| Field | Type | Description |
|---|---|---|
| season_id * | string | |
| format_id | string | Limit the rebuild to one format. Omit for the whole season. |
Responses
202Recompute accepted.| Field | Type | Description |
|---|---|---|
| job_id | string | |
| status | string |
403Caller lacks admin privileges.| Field | Type | Description |
|---|---|---|
| code * | string | |
| message * | string | |
| details | array of object |
Source of truth. Click any endpoint to expand its request payload and
responses. The authoritative machine-readable contract lives in
openapi.yaml (OpenAPI 3.1), emitted from the
FastAPI route definitions at build time, so code and contract cannot drift.
What the model encodes, and why
Design choices that would be expensive to reverse, recorded alongside the shape they produced.
| Decision | Choice | Rationale |
|---|---|---|
| Rating scope | Both per-format Elo and general season Elo are Elo, and both are scoped to a Season. | Every board is a fresh race when a season opens. Rating.format_id null means general. No cross-season carry-over. |
| Multiplayer scoring | Configured per Season as placement or kill, immutable after activation. |
One consistent rule per competitive period. Placement pods write placement; kill pods write MatchElimination rows as pairwise exchanges. |
| Match source | Four ingest paths converge on one append-only ledger: direct, internal, challonge, melee. |
One rating engine, one achievement evaluator, one audit trail regardless of where the result came from. |
| Identity resolution | PlayerExternalAccount.external_username is the join key for imported participants. |
Melee has no public profile API, so username fallback is the realistic path. Unmatched participants park in ImportQueueItem rather than applying as anonymous results. |
| Integration credentials | Admin brings their own Challonge / Melee keys via PUT /admin/integrations/{provider}, stored encrypted, never returned. |
Deployment-scoped rather than per-player. Melee restricts API access to approved organizations, so the key belongs to whoever runs the community. |
| Achievement awards | Automatic via a fixed criteria vocabulary, or manual with player proof and an admin grant. Rewards are free text. | Fixed vocabulary keeps the evaluator small and testable. Free-text rewards defer the delivery machinery without blocking the feature. |
| Decklist card data | Scryfall ids (card_ref) with a CardProvider port resolving names, sets and images. |
Scryfall is the community standard and free. The port keeps the display layer independent of any one card source. |
| Layering | Clean architecture: pure domain, use cases over port interfaces, adapters at the edge. | Provider integrations (Melee especially) can ship late or be replaced without touching the rating engine or use cases. |
| Stack | Python API (FastAPI) · React SPA (Node) · PostgreSQL. | FastAPI emits the OpenAPI contract from code, so the documentation requirement and the API contract are one artifact. |
| Out of scope | Multi-tenancy. Single community per deployment. | Avoids threading community_id through every table and query. Revisitable later. |
External dependency to verify early. Melee's API is gated: access is
granted only to organizations and their designated staff, and the org owner must
confirm via contact@melee.gg before a client id and secret are issued.
MeleeSource sits behind the same TournamentSource port as
ChallongeSource, so it can ship later without affecting any use case —
but the application should be filed before the integration slice is scheduled.