Scoreboard API
The IRL game scoreboard at /scoreboard. Mounted at /api/scoreboard. Three nouns: games (what you play), players (who plays), and matches (one session of a game, live until you finish it). Shipped 2026-08-25 (SHAN-435).
Model
- Colors (games and players):
amber sky emerald fuchsia rose violet lime cyan orange teal. Omitcoloron create and one is assigned round-robin from the current row count, so a fresh roster comes out visually distinct without you choosing. - Match
statusisliveorfinal. A live match haswinnerPlayerId: nulland mutable scores; finishing it sets a winner and freezes scoring until you reopen it. - A game's icon is game-icons.net art, stored denormalized as
{path, viewBox, slug}so rendering never depends on GitHub being up.slugiscategory/name, e.g.delapouite/chess-king. - Games and players are global (anyone's reads see the same roster), but mutations are owner-scoped to whoever created the row.
Reads (public)
| Method | Path | Notes |
|---|---|---|
| GET | /games | {games, stats}, oldest first. stats is [{gameId, playerId, wins}] counted over final matches only, so a game nobody has finished is simply absent |
| GET | /players | {players}, oldest first |
| GET | /matches | {matches} newest first, each with an embedded players array of {playerId, name, color, score, position} |
| GET | /matches/:id | {match}, same shape; 404 if unknown |
GET /matches takes gameId, status (live or final), limit (1..100, default 50) and cursor. The cursor is an ISO datetime on createdAt, not a date string (the journal's date-based cursor does not apply here): pass the createdAt of the last match you saw to get the next page.
Reads (authed)
| Method | Path | Notes |
|---|---|---|
| GET | /icons/search?q= | {results} of {slug, previewUrl, ...}. Requires a token (any scope) because it spends GitHub API quota. q is 2..60 chars. If GitHub is unreachable on a cold cache it returns {results: []} rather than an error, so treat an empty list as "try another word", not as failure |
Writes
Every write needs scope entries:write and shares PAT bucket scoreboard-write (60/min); browser JWT sessions bypass the limit. Mutations are owner-scoped and answer 404 Not found or not owner rather than 403, so you cannot probe for rows you do not own. Every PATCH requires at least one field.
Games
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /games | {name, iconSlug, description?, rules?, color?} | name <=120, description/rules <=4000. The icon art is fetched server-side at create time; an unreachable or unknown iconSlug is a 502, not a 400 |
| PATCH | /games/:id | any of name, description, rules, iconSlug, color | passing iconSlug re-fetches the art, same 502 on failure |
| DELETE | /games/:id | none | 204. Cascades: every match of that game is deleted with it, silently and with no 409 guard, unlike player deletion below |
Players
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /players | {name, color?} | name <=80 |
| PATCH | /players/:id | {name?, color?} | |
| DELETE | /players/:id | none | 204, but 409 if the player appears in any match. Delete those matches first |
Matches
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /matches | {gameId, playerIds, location?} | 2..8 player ids, must be unique (400 on duplicates) and must all exist (400 Unknown player id). Scores start at 0 |
| PATCH | /matches/:id | {location} | the only editable match field, and the only edit still allowed on a final match (where the game happened is a record, not a score) |
| PATCH | /matches/:id/score | {playerId, delta} | delta is exactly 1 or -1; anything else fails validation. 409 if the match is already final, 404 if the player is not in this match |
| POST | /matches/:id/finish | {winnerPlayerId} | 400 if the winner is not a participant, 409 if the match is already final |
| POST | /matches/:id/reopen | none | back to live and scores become mutable again; 409 if the match is not final |
| DELETE | /matches/:id | none | 204, removes its participant rows too |
Example
Record one match end to end:
API=https://shanebackend-production.up.railway.app/api/scoreboard
# 1. create a game (icon art is fetched server-side, so this can take a second)
curl -X POST $API/games -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"name":"Catan","iconSlug":"delapouite/chess-king","rules":"First to 10 points"}'
# 2. two players, then a live match between them
curl -X POST $API/players -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" -d '{"name":"Shane"}'
curl -X POST $API/matches -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"gameId":"<GAME_ID>","playerIds":["<P1>","<P2>"],"location":"Dallas"}'
# 3. score, then freeze it
curl -X PATCH $API/matches/<MATCH_ID>/score -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"playerId":"<P1>","delta":1}'
curl -X POST $API/matches/<MATCH_ID>/finish -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"winnerPlayerId":"<P1>"}'
Once finished, the match counts toward stats on GET /games. Further /score calls answer 409 until you POST /matches/:id/reopen.