Stumper
Trivia game endpoints. All under /v1/games/stumper.
Multiplayer lobby endpoints — create, join, configure, play — live in their own page: Stumper Lobbies.
| You want to… | Go to |
|---|---|
| Browse cached / pull new questions for solo play | Questions |
| Run an endless or daily challenge | Runs, Challenges |
| Save / unsave a question | Favorites |
| Read the leaderboard | Leaderboards |
| Host or join a multiplayer match | Stumper Lobbies → |
Categories
GET /v1/games/stumper/categories
List available trivia categories with question counts.
- Auth: Public
- Cache: 10 minutes
Response — 200 OK
{
"categories": [
{ "name": "world-history", "num_questions": 4211 },
{ "name": "geography", "num_questions": 3876 }
]
}
POST /v1/games/stumper/categorize
Suggest canonical categories matching a free-form prompt. Lets the player type natural language and get back category keys the generator understands.
- Auth: Public
- Rate limit: 20 / 60s
Request
{
"prompt": "battles of world war 2"
}
Response — 200 OK
{
"categories": [ "world-history", "military-history" ]
}
Questions
Both endpoints return practice questions, but they serve different flows.
POST /questions/generate | GET /questions/draw | |
|---|---|---|
| Returns | up to 20 questions in one batch | one unseen question at a time |
| Use for | preparing a lobby, exploring a category | the practice screen, endless mode |
| Generates from Anthropic? | yes, on cache miss | yes, on cache miss |
| Filters out already-answered | yes | yes |
POST /v1/games/stumper/questions/generate
Generate (or fetch from cache) up to 20 trivia questions. Questions the caller has already answered are filtered out automatically.
- Auth: Authenticated
- Rate limit: 30 / 60s
Request
{
"category": "world-history",
"difficulty": "hard",
"count": 10,
"prompt": "focus on napoleonic wars",
"model": "claude-sonnet-4-20250514"
}
| Field | Type | Constraints |
|---|---|---|
category | string | required. Category name or random. |
difficulty | string | default auto. easy|medium|hard|elementary|middle_school|high_school|undergrad|masters|phd|postdoc|auto. |
count | integer | 1–20, default 10. |
prompt | string|null | max 500 chars, [\p{L}\p{N}\s.,!?'"\-:;()/]. |
model | string|null | optional override: claude-haiku-4-5-20251001, claude-sonnet-4-20250514, claude-opus-4-20250514. |
Response — 200 OK
{
"questions": [
{
"id": 18273,
"type": "multiple_choice",
"category": "world-history",
"difficulty": "hard",
"question": "Which battle marked the end of the Napoleonic Wars?",
"payload": {
"choices": [ "Austerlitz", "Waterloo", "Leipzig", "Borodino" ],
"estimated_rate": 72
},
"is_cached": false
}
]
}
POST /v1/games/stumper/questions/{id}/answer
Submit an answer to a single practice question. Records the answer, updates stats, awards XP, and returns the reveal.
- Auth: Authenticated
Path params: id — question ID.
Request
{
"selected_index": 1,
"timer_duration_ms": 30000,
"time_taken_ms": 8420
}
| Field | Type | Constraints |
|---|---|---|
selected_index | integer | required. 0–3, or -1 for timeout. |
timer_duration_ms | integer|null | 0–300000. |
time_taken_ms | integer|null | 0–300000. |
Response — 200 OK
{
"is_correct": true,
"answer": 1,
"explanation": "Waterloo (1815) was Napoleon's final defeat…",
"correct_rate": 72,
"num_served": 1204,
"xp_gained": 18,
"session": {
"id": 8172,
"player_id": 42,
"total_points": 24100,
"total_correct": 118,
"total_answered": 142,
"current_streak": 7,
"best_streak": 19,
"current_multiplier": 2,
"created_at": "2026-04-18T11:20:00+00:00",
"updated_at": "2026-04-18T12:00:00+00:00",
"ended_at": null
},
"was_previously_wrong": false
}
Errors
| Code | Meaning |
|---|---|
404 | Question not found. |
GET /v1/games/stumper/questions/draw
Draw a single practice question. Picks an unseen question matching the filters, or returns null if none available.
- Auth: Authenticated
Query params
| Field | Notes |
|---|---|
categories | comma-separated category names. |
category | single category name. |
difficulty | default auto. |
prompt | natural-language filter. |
retry | true to draw a previously-wrong question. |
blend | true to blend across categories. |
Response — 200 OK
{
"question": {
"id": 18273,
"type": "multiple_choice",
"category": "world-history",
"difficulty": "hard",
"question": "…",
"payload": { "choices": [ "…" ], "estimated_rate": 72 },
"is_cached": true,
"is_retry": false
}
}
question is null when no unseen question is available.
GET /v1/games/stumper/questions
List cached trivia questions. Offline-friendly paginated catalog for admin tooling and offline play.
- Auth: Public
- Cache: 5 minutes
Query params
| Field | Notes |
|---|---|
category | filter by category. |
difficulty | filter by difficulty. |
limit | default 10. |
offset | default 0. |
Response — 200 OK
{
"questions": [
{
"id": 18273,
"type": "multiple_choice",
"category": "world-history",
"difficulty": "hard",
"question": "…",
"answers": [ "Austerlitz", "Waterloo", "Leipzig", "Borodino" ],
"correct_index": 1,
"explanation": "…"
}
]
}
This endpoint reveals the correct index because it's intended for admin tooling, not gameplay.
Favorites
GET /v1/games/stumper/favorites
List the caller's favorited questions with full formatted question data.
- Auth:
ROLE_REGISTERED
Response — 200 OK
{
"favorites": [
{
"id": 314,
"question": {
"id": 18273,
"type": "multiple_choice",
"category": "world-history",
"difficulty": "hard",
"question": "…",
"answers": [ "…" ],
"correct_index": 1,
"explanation": "…"
},
"created_at": "2026-04-15T09:00:00+00:00"
}
]
}
POST /v1/games/stumper/favorites
Favorite a question.
- Auth:
ROLE_REGISTERED
Request
{
"question_id": 18273
}
Response — 201 Created
{
"favorite": {
"id": 314,
"question_id": 18273,
"created_at": "2026-04-15T09:00:00+00:00"
}
}
Errors
| Code | Meaning |
|---|---|
404 | Question not found. |
409 | Already favorited. |
DELETE /v1/games/stumper/favorites/{questionId}
Unfavorite a question.
- Auth:
ROLE_REGISTERED
Response — 204 No Content.
Errors
| Code | Meaning |
|---|---|
404 | Favorite not found. |
Player state
GET /v1/games/stumper/state
Full player state in a single request. Hydrates the UI on page load with preferences, overall stats, per-category stats, seen question IDs, and any active play session.
- Auth: Authenticated
Response — 200 OK
{
"preferences": {
"sound": true,
"autoplay": false,
"theme": "dark"
},
"stats": {
"total_score": 132400,
"total_answers": 1402,
"total_correct": 1018,
"best_streak": 47,
"total_xp": 28500
},
"category_stats": {
"world-history": {
"xp": 4200,
"level": 8,
"xp_in_level": 200,
"xp_for_next_level": 600,
"percentage": 33.3,
"total_answers": 221,
"total_correct": 178,
"best_streak": 19
}
},
"seen_question_ids": [ 18273, 18274, 18275 ],
"play_session": {
"id": 8172,
"player_id": 42,
"total_points": 24100,
"total_correct": 118,
"total_answered": 142,
"current_streak": 7,
"best_streak": 19,
"current_multiplier": 2,
"created_at": "2026-04-18T11:20:00+00:00",
"updated_at": "2026-04-18T12:00:00+00:00",
"ended_at": null
}
}
POST /v1/games/stumper/session/end
Explicitly end the active play session. Called when the player resets or starts a new session.
- Auth: Authenticated
Response — 200 OK
{ "ok": true }
GET /v1/games/stumper/preferences
Get the caller's Stumper preferences. Free-form key/value map; the server does not enforce a schema.
- Auth: Authenticated
Response — 200 OK
{
"preferences": { "sound": true, "autoplay": false, "theme": "dark" }
}
PATCH /v1/games/stumper/preferences
Merge new keys into the caller's preferences. Existing keys not in the body are preserved.
- Auth: Authenticated
Request
{ "theme": "retro", "autoplay": true }
Response — 200 OK
{
"preferences": { "sound": true, "autoplay": true, "theme": "retro" }
}
Runs
Solo run modes: endless, daily_shuffle, gauntlet, and challenge-completion.
POST /v1/games/stumper/runs
Start a new run.
- Auth: Authenticated
Request
{
"mode": "endless",
"challenge_id": null,
"category": "world-history"
}
| Field | Type | Constraints |
|---|---|---|
mode | string | required. endless | daily_shuffle | gauntlet. |
challenge_id | integer|null | required for challenge-backed runs. |
category | string|null | optional category or random. |
Response — 200 OK
{
"id": 7201,
"mode": "endless",
"challenge_id": null,
"score": 0,
"streak": 0,
"best_streak": 0,
"num_correct": 0,
"current_index": 0,
"total_time_ms": 0,
"category": "world-history",
"is_finished": false,
"created_at": "2026-04-18T12:00:00+00:00",
"finished_at": null,
"question": { "…": "next question with correct_index revealed server-side only at reveal time" }
}
Errors
| Code | Meaning |
|---|---|
409 | Challenge already completed. |
GET /v1/games/stumper/runs/{id}
Get the current state of a run.
- Auth: Authenticated
Response — 200 OK
{
"run": {
"id": 7201,
"mode": "endless",
"challenge_id": null,
"score": 420,
"streak": 3,
"best_streak": 9,
"num_correct": 12,
"current_index": 12,
"total_time_ms": 92000,
"category": "world-history",
"is_finished": false,
"created_at": "2026-04-18T12:00:00+00:00",
"finished_at": null
}
}
Errors
| Code | Meaning |
|---|---|
404 | Run not found. |
POST /v1/games/stumper/runs/{id}/answer
Submit an answer to the current question in a run. Returns the reveal plus the next question, or null if the run finished.
- Auth: Authenticated
Request
{
"selected_index": 1,
"time_taken_ms": 8200
}
selected_index is 0–3 or -1 for timeout. time_taken_ms is 0–600000.
Response — 200 OK
{
"is_correct": true,
"answer": 1,
"explanation": "…",
"correct_rate": 72,
"num_served": 1204,
"xp_gained": 18,
"points_earned": 140,
"streak": 4,
"best_streak": 9,
"next_question": { "…": "next question or null" },
"run_finished": false
}
Errors
| Code | Meaning |
|---|---|
404 | Run not found or already finished. |
POST /v1/games/stumper/runs/{id}/advance
Advance a challenge run to the next question after answering. Separated from /answer so a refresh between answering and clicking "Next" restores the reveal screen instead of skipping to the next question. Endless runs advance automatically inside /answer, so calling this on an endless run returns 409.
- Auth: Authenticated
Path params: id — run ID.
Request — No body.
Response — 200 OK
{
"run": {
"id": 7201,
"mode": "gauntlet",
"challenge_id": 813,
"score": 560,
"streak": 4,
"best_streak": 9,
"num_correct": 13,
"current_index": 13,
"total_time_ms": 100200,
"category": null,
"is_finished": false,
"created_at": "2026-04-18T12:00:00+00:00",
"finished_at": null
},
"question": { "…": "next question in safe form, or null if not found" }
}
Errors
| Code | Meaning |
|---|---|
404 | Run or challenge not found. |
409 | Run already finished, endless mode (auto-advances), current question not answered yet, or no more questions. |
Challenges
GET /v1/games/stumper/challenges/today
Today's challenges with completion status for the current player. Accepts a timezone so "today" resolves in the player's local day.
- Auth: Authenticated
Query params
| Field | Default |
|---|---|
tz | UTC. IANA identifier. |
Response — 200 OK
{
"daily_shuffle": {
"id": 812,
"completed": false,
"in_progress": true,
"run_id": 7201,
"score": null,
"rank": null,
"category": "world-history",
"categories": [ "world-history" ],
"difficulties": [ "easy", "medium", "hard" ]
},
"gauntlet": {
"id": 813,
"completed": true,
"in_progress": false,
"run_id": 7150,
"score": 2400,
"rank": 42,
"category": null,
"categories": [ "world-history", "geography", "science" ],
"difficulties": [ "easy", "medium", "hard" ]
}
}
category is set only when every question in the challenge shares one category (always true for daily_shuffle, rare for gauntlet). rank and score are null until the player finishes the run.
Leaderboards
Three leaderboards — pick the one whose scope matches the audience you're showing it to.
| Endpoint | Scope | Best for |
|---|---|---|
/leaderboard | All players, all-time | Site-wide ranking pages, profile cards |
/leaderboard/challenges/{id} | A single daily / gauntlet challenge | "Today's challenge" leaderboard, post-run results |
/leaderboard/teams | All teams, all-time | Team leaderboards, league standings |
GET /v1/games/stumper/leaderboard
Global leaderboard. Sortable by score, accuracy, streak, or xp. Paginated, optional name search.
- Auth: Optional (appends caller's rank if authenticated)
Query params
| Field | Notes |
|---|---|
tab | score | accuracy | streak | xp. Default score. |
limit | default 20, max 100. |
page | default 1. |
search | min 2 chars, ILIKE on player name. |
Response — 200 OK
{
"entries": [
{
"rank": 1,
"rank_prev": 2,
"player_id": 42,
"name": "anders",
"value": 132400,
"total_score": 132400,
"total_xp": 28500,
"total_answers": 1402,
"accuracy": 72,
"best_streak": 47,
"level": 31,
"is_player": true,
"is_guest": false,
"avatar_v": 1713456000
}
],
"total": 18402,
"page": 1,
"has_more": true,
"player_rank": { "rank": 1, "value": 132400 }
}
value reflects the selected tab. player_rank is null for unauthenticated callers.
GET /v1/games/stumper/leaderboard/challenges/{challengeId}
Leaderboard for a specific daily challenge. Returns two sections: the top entries and a context window around the caller's rank.
- Auth: Optional
Query params
| Field | Notes |
|---|---|
top | default 5, max 100. |
radius | default 4, max 10. Context window radius around caller. |
page | default 1. |
search | min 2 chars. |
Response — 200 OK
{
"top": [
{
"rank": 1,
"player_id": 42,
"name": "anders",
"score": 2400,
"num_correct": 18,
"best_streak": 12,
"total_time_ms": 48000,
"is_player": true
}
],
"context": [],
"player_rank": 1,
"total": 872,
"page": 1,
"has_more": true
}
GET /v1/games/stumper/leaderboard/teams
Team leaderboard. Same pagination mechanics as the global leaderboard.
- Auth: Optional
Query params
| Field | Notes |
|---|---|
metric | score | accuracy | streak. Default score. |
limit | default 20, max 100. |
page | default 1. |
Response — 200 OK
Shape from TeamService::leaderboard():
{
"entries": [ { "rank": 1, "team_id": 7, "name": "Team Trivia", "value": 48200 } ],
"total": 42,
"page": 1,
"has_more": true
}
Lobbies
Multiplayer lobby endpoints — create, join, configure, play, leave — moved to a dedicated page.