Tournament Feature — Frontend Spec¶
Audience: frontend engineer building the tournament UI against the Genji Shimada API. Verified against the live code (
apps/api/routes/v3/tournaments.py,libs/sdk/src/genjishimada_sdk/tournaments.py) as of migration0025.
What the web frontend is: a public read-only display of tournaments + an admin dashboard for staff. That is the whole surface.
What the web frontend is NOT: it does not submit times and it does not verify/reject runs. Players submit their tournament times by submitting a normal completion via the Discord bot (the API auto-detects the active tournament cycle by map code). Mods verify/reject through the bot's mod-review flow. There is no web submit UI — do not build one.
Base path for everything below: /api/v3/tournaments
1. Mental model — Edition vs Cycle vs Category¶
- Category — a difficulty-grouped tournament track (e.g. "Hard", "Extreme") with its
own reward config (XP tiers, champion role). Categories no longer carry cadence — that is
now global config (see Edition / config). Status:
is_activetoggle. - Cycle — one run of a category on one selected map. Exactly one
activecycle per category at a time. Status flow:pending → active → finalizing → completed. A pending cycle is the pre-rolled next map (admin preview only). Cycles link internally to an edition. - Edition — the shared, grid-anchored timing parent spanning all categories. It
carries the one shared
started_at/ends_atfor the whole tournament term. Status flow:active → awaiting_results → completed. The countdown the frontend renders comes fromedition.ends_atdirectly — it is a stored field, not computed client-side.
Edition #7 status=active started_at=2026-05-23T00:00:00Z ends_at=2026-05-30T00:00:00Z
├── Category "Hard" active cycle → map=DEF456 pending cycle → map=GHI789
└── Category "Extreme" active cycle → map=JKL012 pending cycle → map=MNO345
Automatic transitions. A pg_cron job runs every minute. At an edition boundary it:
1. finalizes the current edition's cycles,
2. sets the edition to awaiting_results (final standings may be deferred until in-flight
verifications drain),
3. starts the next edition at the exact grid boundary — started_at of the new
edition = ends_at of the previous one, never now() (this is the drift fix),
4. pre-rolls the next pending cycle per category.
The frontend learns about transitions by re-polling (see §6). There is no API call that triggers a transition.
awaiting_results— the edition's term ended but final standings are deferred pending the drain of in-flight verifications. Results publish automatically once verifications settle, or can be force-published viaPATCH /publish-results.
2. Auth & scopes¶
All endpoints require an authenticated request via the X-API-KEY header (same as the rest
of the v3 API).
| Scope | Use |
|---|---|
tournaments:read |
All GET endpoints (public display) |
tournaments:write |
Admin dashboard mutations (category CRUD, config, map select/reroll, pause, bootstrap, publish-results) |
tournaments:verify |
Bot verify/reject only — not the web dashboard |
Superusers bypass scope checks. A missing/insufficient scope returns 401.
3. Data models¶
All times are floats in seconds. All timestamps are ISO-8601 datetimes. null is
possible wherever noted. These are the fields that exist now — do not assume others.
Config — TournamentConfigResponse¶
Global config singleton (cadence is global, never per-category).
{
"blacklist_weeks": 4, // weeks a map is excluded after use
"cadence": "weekly", // "weekly" | "biweekly" (GLOBAL)
"anchor_weekday": 1, // grid anchor weekday, 0=Sun..6=Sat
"anchor_time": "12:00:00", // wall-clock time-of-day in anchor_tz
"anchor_tz": "America/New_York", // IANA timezone name
"transitions_paused": false, // global hiatus lever
"debug_cycle_seconds": null, // int | null — debug/test edition-length override
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z"
}
Category — TournamentCategoryResponse¶
No cycle_frequency — cadence is global config.
{
"id": 1,
"name": "Hard",
"difficulties": ["Hard", "Very Hard"], // top-level DifficultyTop tiers (see §7)
"participation_xp": 50, // flat XP for first submission in a cycle
"placement_xp": [{"place": 1, "xp": 200}, {"place": 2, "xp": 100}],
"streak_xp": [{"threshold": 5, "xp": 300}],
"champion_role_id": 123456789012345678, // Discord role id, or null
"is_active": true,
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z"
}
Edition — TournamentEditionResponse¶
The shared timing parent. ends_at is stored, read it directly.
{
"id": 7,
"started_at": "2026-05-23T00:00:00Z", // exact grid value (anchor + N×period), never now()
"ends_at": "2026-05-30T00:00:00Z", // STORED, not derived — use for the countdown
"status": "active", // active|awaiting_results|completed
"created_at": "2026-05-16T00:00:00Z"
}
Cycle (list/archive) — TournamentCycleWithWinnerResponse¶
{
"id": 13,
"category_id": 1,
"map_id": 456,
"map_code": "DEF456",
"map_name": "Parkour Paradise",
"map_difficulty": "Hard", // string label
"status": "active", // pending|active|finalizing|completed
"started_at": "2026-05-23T00:00:00Z", // null while pending
"ended_at": null, // set when completed
"created_at": "2026-05-16T00:00:00Z",
"winner_name": null, // rank-1 display name, null if no subs
"winner_user_id": null
}
Cycle list wrapper — TournamentCycleListResponse¶
Next-cycle preview — TournamentNextCycleResponse¶
{
"id": 14, "category_id": 1, "map_id": 789,
"map_code": "GHI789", "map_name": "Sky Temple", "map_difficulty": "Very Hard",
"status": "pending", "created_at": "2026-05-16T00:00:00Z"
}
Leaderboard entry — TournamentLeaderboardEntryResponse¶
{
"rank": 1,
"user_id": 140728390589939712,
"name": "PlayerX",
"time": 42.51, // seconds
"verified": true, // verified always outranks unverified (see §7)
"completion": true // counts as a full completion (quest/badge eligibility)
}
Streak — TournamentStreakResponse¶
{
"user_id": 140728390589939712,
"current_streak": 3,
"max_streak": 7,
"last_cycle_id": 13, // int | null
"updated_at": "2026-05-24T12:00:00Z"
}
Lifecycle — TournamentLifecycleResponse¶
Returned by PATCH /pause and PATCH /debug-cycle-length.
Request bodies (admin dashboard)¶
TournamentCategoryCreateRequest—nameanddifficultiesrequired;participation_xp=0,placement_xp=[],streak_xp=[],champion_role_id=nulldefault. Nocycle_frequency.TournamentCategoryPatchRequest— all optional:name,difficulties,participation_xp,placement_xp,streak_xp,champion_role_id,is_active.TournamentConfigPatchRequest— all optional:blacklist_weeks,cadence,anchor_weekday,anchor_time,anchor_tz.TournamentChooseMapRequest—{ "map_code": "ABC123" }.TournamentPauseRequest—{ "paused": true }.TournamentDebugCycleLengthRequest—{ "seconds": 60 }(ornullto clear).
4. Endpoints¶
Public read — scope tournaments:read¶
| Method | Path | Returns | Notes |
|---|---|---|---|
GET |
/config |
TournamentConfigResponse |
Global config incl. cadence + anchor. |
GET |
/categories |
TournamentCategoryResponse[] |
Build the category selector from this. |
GET |
/categories/{id} |
TournamentCategoryResponse |
404 if not found. |
GET |
/streaks/{user_id} |
TournamentStreakResponse |
404 = no streak yet (treat as 0). |
GET |
/cycles |
TournamentCycleListResponse |
Main list/archive endpoint. Query params below. |
GET |
/cycles/{cycle_id}/leaderboard |
TournamentLeaderboardEntryResponse[] |
Unpaginated, already ranked. |
GET |
/categories/{id}/next-cycle |
TournamentNextCycleResponse |
404 if category or pending cycle missing. |
GET |
/editions/active |
TournamentEditionResponse |
The shared timing window. 404 if none active. |
GET /cycles query params:
- status — pending | active | finalizing | completed (optional)
- category_id — int (optional)
- limit — 1–100, default 20
- offset — default 0
Getting the current active cycle for a category (common): there is no dedicated endpoint. Call
GET /cycles?status=active&category_id={id}and takecycles[0](0 or 1 result). Thecycle_idis what you pass to/leaderboard.
Admin dashboard write — scope tournaments:write¶
| Method | Path | Body | Returns | Errors |
|---|---|---|---|---|
POST |
/categories |
TournamentCategoryCreateRequest |
TournamentCategoryResponse (201) |
409 name exists |
PATCH |
/categories/{id} |
TournamentCategoryPatchRequest |
TournamentCategoryResponse (200) |
404 · 409 locked or name conflict |
DELETE |
/categories/{id} |
— | 204 |
404 · 409 locked |
PATCH |
/config |
TournamentConfigPatchRequest |
TournamentConfigResponse (200) |
422 invalid anchor_tz |
POST |
/categories/{id}/select-map |
— | TournamentNextCycleResponse (201) |
404 · 409 pending exists · 422 no eligible maps |
POST |
/categories/{id}/reroll |
— | TournamentNextCycleResponse (201) |
404 · 422 no eligible maps |
POST |
/categories/{id}/reroll-active |
— | TournamentNextCycleResponse (201) |
404 category/active not found · 422 no eligible maps |
PATCH |
/categories/{id}/next-cycle |
TournamentChooseMapRequest |
TournamentNextCycleResponse (200) |
404 · 422 map not eligible |
POST |
/bootstrap |
— | TournamentEditionResponse (201) |
409 active edition exists · 422 a category has no eligible maps |
PATCH |
/publish-results |
— | 204 |
409 if no edition awaiting_results |
PATCH |
/pause |
TournamentPauseRequest |
TournamentLifecycleResponse (200) |
— |
PATCH |
/debug-cycle-length |
TournamentDebugCycleLengthRequest |
TournamentLifecycleResponse (200) |
403 in production |
Notes on the admin mutations:
- reroll discards the pending cycle and re-randomizes the next map.
- reroll-active rerolls the live active cycle's map — it wipes that cycle's
submissions and preserves the edition window (the deadline does not move).
- next-cycle (PATCH) explicitly chooses the pending map by map_code.
- bootstrap manually activates the first grid-snapped edition (one edition + one
child cycle per active category). Thereafter rollover is automatic.
- publish-results force-publishes results, ignoring in-flight verifications.
- pause is a global hiatus: the active edition still finishes its term, only the
next edition is suppressed.
- debug-cycle-length is DEBUG/TEST ONLY (returns 403 in production); pass
seconds: null to clear the override.
"Locked" (
409) on category edit/delete means the category currently has anactiveorfinalizingcycle. Surface this as "can't edit while a tournament is running."
Discord-bot only — NOT the web dashboard — scope tournaments:verify¶
| Method | Path | Returns | Errors |
|---|---|---|---|
PATCH |
/completions/{tournament_completion_id}/verify |
JobStatusResponse |
404 |
PATCH |
/completions/{tournament_completion_id}/reject |
JobStatusResponse |
404 · 409 already verified (verified is terminal) |
These exist for the bot's mod-review flow. The web dashboard does not call them.
There is no POST /cycles/{cycle_id}/submit. Tournament completions are created
server-side when a user submits a normal completion via POST /api/v3/completions/ —
the API auto-detects an active tournament cycle by map code. That path is async/job-based
(it returns a CompletionSubmissionJobResponse with job_status and completion_id), not a
synchronous 201. The web frontend does not submit tournament times.
5. Frontend flows¶
A. View the active tournament + countdown¶
GET /editions/active→ the shared window (started_at,ends_at,status).- For each category:
GET /cycles?status=active&category_id={id}→ takecycles[0]for the current map (map_name,map_code,map_difficulty). - Countdown =
edition.ends_at − now(), read straight from the storedends_at. Do not derive it fromstarted_at+ cadence.
B. Leaderboard¶
GET /cycles/{cycle_id}/leaderboard→ already ranked; render as-is.
C. Archive / past champions¶
GET /cycles?status=completed(optionally&category_id={id}). Each row carrieswinner_name/winner_user_idandended_at— enough for a "Past Champions" list. Usetotalfor pagination.
D. Streaks¶
GET /streaks/{user_id}→current_streak/max_streak. Treat404as "no streak yet" (show 0), not an error.
E. Admin dashboard¶
- Category CRUD (
POST/PATCH/DELETE /categories). - Config (
PATCH /config: cadence + anchor). - Map management: preview (
GET .../next-cycle), random select (POST .../select-map), reroll pending (POST .../reroll), reroll live (POST .../reroll-active), explicit choose (PATCH .../next-cycle). - Lifecycle: pause/resume (
PATCH /pause), bootstrap first edition (POST /bootstrap), force-publish (PATCH /publish-results).
6. Polling & async behavior¶
- All admin writes are synchronous and return the final result — except verify/reject and tournament-completion submission, which are async/job-based (and both happen via the bot, not the web frontend).
- Edition/cycle transitions are automatic and server-driven (a
pg_cronjob runs every minute). There is no event the browser receives. - To detect a transition, re-poll
GET /editions/activeand/orGET /cycles?status=active&category_id={id}. A changed editionid/ends_ator cycleid/started_atmeans a transition happened. Suggested cadence: every 30–60s on an open tournament page, plus on user focus. - Render the countdown from the stored
edition.ends_at. - Discord announcements and XP grants happen via the bot/queue after a transition; they do not change any API response and need no frontend handling.
7. Reference values¶
Cycle status (status): pending, active, finalizing, completed.
- pending — pre-rolled next map, admin preview only.
- active — the live cycle for the current edition term.
- finalizing — brief transient state during finalization.
- completed — done; results frozen; appears in archive.
Edition status (status): active, awaiting_results, completed.
Cadence (config.cadence, GLOBAL): weekly or biweekly.
Difficulty tiers (difficulties on a category — DifficultyTop, top-level only):
Easy, Medium, Hard, Very Hard, Extreme, Hell.
(Maps internally have ± sub-tiers, but categories group by these top-level tiers. Note
map_difficulty on cycle responses is a free-form string label, not the typed enum.)
Leaderboard ranking rule: verified DESC, then time ASC — a verified run always
outranks an unverified one; within a tier the fastest wins. rank is precomputed; render
as-is.
verified vs completion: verified means the run passed verification and so affects
its ranking tier (verified always outranks unverified). completion means the submission
counts as a full completion for quest/badge eligibility — a distinct flag from verified.
Champion: the winner_user_id of a completed cycle (rank 1 at finalization). The
Discord role transfer is handled bot-side; category.champion_role_id names the role
representing that title (may be null).