API Reference — Flask (REST) + Flask-SocketIO (real-time gameplay)
state_update whenever anything changes.
docs/PROTOCOL.md in this repo is the enforced source of truth for the wire contract (every route/event, cross-checked against tests/test_wire_format.py's golden tests) — update both in the same PR as any wire-shape change.
/create_lobby, /join_lobby/<id>, /get_bossfight_lobby, or the join_lobby socket event) mints an opaque per-player token, handed back exactly once in that call's response. Nothing after that trusts a name/admin field from the request — see Session Tokens below.
{"error": "Internal server error"} (an unexpected exception, logged server-side with its traceback) or 429 {"error": "Too many requests. Please try again later."} (rate limit exceeded) via app-level generic handlers. These aren't repeated per endpoint below.
Registers a new player name linked to an email, or attaches an email to a name that was previously unclaimed.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 3–12 characters, no spaces or special characters |
email | string | Yes | Valid email address |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true} | Name claimed or email updated |
| 400 | {"error": "Name must be 3–12 characters long"} | Invalid name format |
| 400 | {"error": "Invalid email."} | Invalid email format |
| 409 | {"error": "Name already claimed."} | Name belongs to another email |
Lets the frontend distinguish "free name" from "needs login" before showing the claim/login form.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Name to check |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"claimed": true} | Name is linked to an email |
| 200 | {"claimed": false} | Name is free or unclaimed |
| 400 | {"error": "Name is required."} | Missing name |
Verifies that a name + email pair matches what is stored in the database. If the account has always_verify_email enabled, this sends a one-time code instead of logging in directly — the client must then call /verify_code.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Registered name |
email | string | Yes | Associated email |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true, "always_verify_email": false} | Credentials verified, logged in |
| 200 | {"success": false, "requires_code": true} | Verification code emailed; call /verify_code next |
| 403 | {"error": "Email does not match."} | Wrong email for that name |
| 404 | {"error": "Name not found."} | Name does not exist |
| 502 | {"error": "Could not send verification email."} | Mailer failure |
Completes login for accounts with always_verify_email enabled. Codes expire after 10 minutes and allow 5 attempts before being invalidated.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Registered name |
code | string | Yes | 6-digit code sent by email |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true, "always_verify_email": true} | Code matched |
| 400 | {"error": "Name and code are required."} | Missing fields |
| 403 | {"error": "Wrong code."} | Code mismatch (attempt counted) |
| 404 | {"error": "No verification pending."} | No code was requested |
| 410 | {"error": "Code expired."} | Past the 10-minute TTL |
| 429 | {"error": "Too many attempts."} | 5 wrong guesses |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Registered name |
email | string | Yes | Must match the stored email |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"always_verify_email": false} | Flag returned |
| 400 | {"error": "Name and email are required."} | Missing fields |
| 403 | {"error": "Email does not match."} | Wrong email |
| 404 | {"error": "Name not found."} | Name does not exist |
Sends a link to {FRONTEND_URL}/email_verified?token=.... The target value is bound to the token server-side so only the emailed link can apply the change.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Registered name |
email | string | Yes | Must match the stored email |
always_verify_email | boolean | Yes | Value to apply once confirmed |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true} | Link emailed |
| 400 | {"error": "..."} | Missing fields |
| 403 | {"error": "Email does not match."} | Wrong email |
| 404 | {"error": "Name not found."} | Name does not exist |
| 502 | {"error": "Could not send verification email."} | Mailer failure |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Token from the emailed link |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true, "always_verify_email": true} | Flag applied |
| 400 | {"error": "Token is required."} | Missing token |
| 404 | {"error": "Invalid or expired link."} | Unknown, or past the 30-minute TTL — expired tokens are purged before the lookup, so both cases resolve to the same 404 |
Creates a new game lobby. The creator becomes the admin. This is the only step done over HTTP — the client then connects over Socket.IO and emits join_room to actually enter it.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Player name (validated against DB) |
email | string | Yes | Player email (must match DB record, if claimed) |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"lobby_id": "<4-char-id>", "token": "..."} | Lobby created; the token authenticates this player and is handed back exactly once, here |
| 400 | {"error": "..."} | Invalid name format |
| 403 | {"error": "This name is already claimed..."} | Email mismatch |
Validates the player and adds them to the lobby's player list. Joining after round 1 has started results in spectator status. The client still needs to emit join_room over the socket afterward to actually receive live state.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Player name |
email | string | Yes | Player email |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"status": "joined", "token": "..."} | Joined; the token is handed back exactly once, here |
| 400 | {"error": "Name taken"} | Name already in lobby |
| 403 | {"error": "This name is claimed..."} | Email mismatch |
| 404 | {"error": "Lobby not found"} | Lobby ID does not exist |
Joining after round >= 1 adds the player as a spectator.
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"history": ["...", "..."]} | Event log returned |
| 200 | {"error": "Lobby not found"} | Unknown lobby — a known status-code bug: no explicit status is set on this branch, unlike every other "lobby not found" case in this file (which return 404). Documented as-is (see docs/CODEBASE_HARDENING_PLAN.md's Phase 4 follow-up note), not fixed here. |
Private to the player named — requires a session token (query param) that resolves to player_name. See Session Tokens.
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"player": "<name>", "messages": [...], "events": [...]} | Messages returned |
| 404 | {"error": "Lobby not found"} | Lobby not found |
| 404 | {"error": "Player not found in this lobby"} | Player not found |
| 403 | {"error": "Missing or invalid session token."} | No/unknown token |
| 403 | {"error": "This token does not authorize access to that player's data."} | Token belongs to a different player |
events are the structured combat/well outcomes the frontend animates from; messages are the display-only text lines. Neither is ever broadcast over state_update — this is the only way to read them.Same token authorization as /get_player_messages above.
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"player": "<name>", "player_history": [...]} | History returned |
| 404 | {"error": "Lobby not found"} | Lobby not found |
| 404 | {"error": "Player not found in this lobby"} | Player not found |
| 403 | {"error": "..."} | Same two token-authorization cases as above |
Joins the currently open cooperative boss-fight lobby, or creates a new one (with a fresh boss and scheduled start time) if none is open. Requires the player to already have an account (from a completed PvP game or /claim_name).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Player name (must exist in DB) |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"lobby_id": "...", "start_time": "...", "token": "..." | null} | Boss-fight lobby joined/created. token is only set when this call actually adds a new player entry — a caller already in the fight gets no token key at all |
| 403 | {"error": "You need to have played at least one game or create an account to fight boss"} | No account yet |
| 500 | {"error": "Failed to create boss fight"} | Server error |
| 500 | {"error": "You are already in a boss fight"} | Duplicate-join attempt with no claimed email on file — a known misfit status code (nothing server-side actually failed), left as-is per docs/CODEBASE_HARDENING_PLAN.md's Phase 3b follow-up note |
{ "start_time": "<ISO 8601 datetime>" }
ensure_bossfight_lobby()) — there is no "lobby not found" branch.Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Player name |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"relics": [...]} | Relics returned |
| 404 | {"error": "Player not found: <name>"} | No such player |
Relic fields: id, boss_id, name, power_category, flavour_text, created_at, count
When a boss is defeated, relic winners without a linked account get a pending_relics entry on the lobby instead of an immediate DB write. This endpoint creates the account (if needed) and awards the relic.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Player name |
email | string | Yes | Email to attach if the account is new |
lobby_id | string | Yes | The boss-fight lobby the relic was won in |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true, "relic_name": "..."} | Relic awarded |
| 400 | {"error": "..."} | Missing/invalid fields |
| 404 | {"error": "Lobby not found"} | Unknown lobby |
| 404 | {"error": "No pending relic for this player"} | Nothing to claim |
| 409 | {"error": "Name already claimed by a different email"} | Name/email conflict |
| 500 | {"error": "Failed to create player account"} | Server error |
/vault_check,
/vault_register_name and /vault_register_email were
removed: there is no passkey to check any more. An artifact is
granted by winning The Well, is bound to one account, and cannot be traded or
found twice. Full contract in docs/PROTOCOL.md.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Account session token |
after | int | No | Keyset cursor on ordinal (default 0) |
limit | int | No | Page size, default 100, max 200 |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"artifacts": [{"ordinal": N, "finder_name": "...", "discovered_at": "..."}], "total": N, "current_chance": 0.001} | The ledger page |
| 400 | {"error": "after and limit must be integers."} | Bad cursor |
| 401 | {"error": "Invalid or expired session."} | Not signed in |
| 403 | {"error": "Only those who have discovered an artifact can read the ledger."} | Not entitled |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Player name that made the discovery |
email | string | Yes | Email to bind the artifact to |
lobby_id | string | Yes | Lobby the discovery happened in |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true, "pending_verification": true} | Verification link sent |
| 400 | {"error": "Invalid email"} | Bad input |
| 404 | {"error": "No pending artifact for this player"} | Nothing to claim |
| 409 | {"error": "This artifact has already been claimed."} | Already bound |
| 502 | {"error": "Could not send verification email."} | Mail failure |
/claim_pending_relic and /claim_pending_wheel,
by design. Unlike those, a failed send leaves the pending entry intact: an
artifact is the one reward in this game that cannot be re-earned.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Account session token |
cosmetic | string | Yes | Cosmetic id, or "" to unequip |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"success": true, "equipped_cosmetic": "artifact_v1" | null} | Equipped or unequipped |
| 400 | {"error": "Unknown cosmetic."} | Not a known id |
| 401 | {"error": "Invalid or expired session."} | Not signed in |
| 403 | {"error": "You do not own this cosmetic."} | Not owned |
/inventory/equip. Unequipping needs no ownership
check: taking something off is always allowed.
No auth, no request body. Runs a SELECT 1 against the database.
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | {"status": "ok"} | DB reachable |
| 503 | {"status": "error"} | DB connection failed (logged with traceback server-side) |
Validates and adds the player to the lobby's player list, same rules as the HTTP endpoint, then broadcasts state_update to the room. Emits joined_lobby back to the caller on success, with a session token that must be presented to join_room next.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
name | string | Player name |
email | string | Player email |
Emits back
| Event | Payload | Meaning |
|---|---|---|
joined_lobby | {"lobby_id": "...", "token": "..."} | Success — the token is handed back exactly once, here |
error | {"message": "..."} | Name taken / invalid / lobby not found |
POST /join_lobby/<lobby_id>, this never sets spectator based on the lobby's round — a known pre-existing gap, not fixed here (see docs/CODEBASE_HARDENING_PLAN.md).The real entry point into a lobby's live game loop. Takes the session token minted by join_lobby (or an HTTP join route) instead of a name — resolving it binds this connection (request.sid) to the player it authenticates (helpers.bind_connection), and every later event on this connection derives the actor from that binding, never from payload fields. Joining the room immediately pushes one state_update to the caller, and — for boss-fight lobbies — starts the background round watcher if it isn't already running.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
token | string | Session token from join_lobby or an HTTP join route |
Emits back
| Event | Payload | Meaning |
|---|---|---|
joined | {"lobby_id": "...", "name": "..."} | Room joined |
state_update | see below | Current lobby state |
error | {"message": "Lobby <id> not found"} | Unknown lobby |
error | {"message": "Invalid or missing session token. Call join_lobby first."} | Token missing/unknown |
error | {"message": "You are not in this lobby. Call POST /join_lobby first."} | Token valid but the player is no longer in this lobby |
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Lobby to leave |
Emits back
| Event | Payload |
|---|---|
left | {"lobby_id": "...", "name": "..." | null} |
name is looked up from this connection's binding, not from the payload — null if the connection was never bound (e.g. join_room was never called).
The caller's identity is derived from this connection's join_room binding, not from the payload — a client can no longer just claim to be the admin.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
Emits back
| Event | Payload | Meaning |
|---|---|---|
state_update | see below | Broadcast to the whole room; round now 1 |
error | {"message": "Lobby not found"} | Unknown lobby |
error | {"message": "Only admin can start the game"} | Not admin |
error | {"message": "Game already started"} | round > 0 |
The caller's identity is derived from this connection's join_room binding, not from the payload. A kicked player's session token and connection binding are revoked immediately, so it stops working right away rather than lingering until it happens to expire.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
target | string | Name of player to kick |
Emits back
| Event | Payload | Meaning |
|---|---|---|
state_update | see below | Broadcast; target removed |
error | {"message": "Lobby not found"} | Unknown lobby |
error | {"message": "You are not the admin"} | Not admin |
error | {"message": "Game already started"} | round > 1 |
error | {"message": "You can't kick yourself"} | Self-kick attempt |
error | {"message": "Player not found"} | Target not in lobby |
The caller's identity is derived from this connection's join_room binding, not from the payload.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
Emits back
| Event | Payload | Meaning |
|---|---|---|
state_update | see below | Broadcast; bot added |
error | {"message": "Lobby not found"} | Unknown lobby |
error | {"message": "Only the admin can add a dummy."} | Not admin |
error | {"message": "Dummy already exists"} | That bot type is already in the lobby |
config.BOT_TYPES (currently just TURTLE, a defend/heal-only bot).Records the player's action/resource/target (the player is derived from this connection's join_room binding, not from the payload), then triggers AI moves for any bots/boss, and — if every alive, non-bot, non-spectator player has now submitted — resolves the round immediately instead of waiting for the timer. Always broadcasts state_update at the end.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
action | string | null | attack, defend, or well |
resource | string | null | gain_hp, gain_coin, or gain_attack |
target | string | null | Target player name (required — and validated as alive/non-spectator/not-self — only when action is attack) |
Emits back
| Event | Payload | Meaning |
|---|---|---|
state_update | see below | Broadcast to the room |
error | {"message": "Too many requests, slow down."} | Rate limited (30 calls per 10s per connection) |
error | {"message": "Lobby not found"} | Unknown lobby |
error | {"message": "Invalid action: the game has ended"} | Game already has a winner |
error | {"message": "Player not found"} | Connection isn't bound to a player in this lobby |
error | {"message": "Invalid action"} / "Invalid resource" | Unknown value |
error | {"message": "Invalid attack target"} | action == "attack" with a missing/dead/spectator/self target |
Only valid for the player currently named in lobby["pending_deny"] (set by the deny_choice Well reward), derived from this connection's join_room binding, not from the payload. The target's action/resource are forced to "denied" for the round.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
target | string | Player to deny |
Emits back
| Event | Payload | Meaning |
|---|---|---|
state_update | see below | Broadcast; pending_deny cleared |
error | {"message": "Invalid"} | Caller is not the pending denier |
The sender is derived from this connection's join_room binding, not from the payload.
Payload
| Field | Type | Description |
|---|---|---|
lobby_id | string | Target lobby |
message | string | Message content (max 200 characters) |
Emits back
| Event | Payload | Meaning |
|---|---|---|
chat_message | {"sender", "message", "timestamp"} | Broadcast to the whole lobby room (not the full history — the client appends it client-side) |
error | {"message": "You're sending messages too fast."} | Rate limited (5 calls per 10s per connection) |
error | {"message": "Lobby not found"} | Invalid lobby ID |
error | {"message": "You are not in this lobby"} | Connection isn't bound to a player in this lobby |
error | {"message": "Message cannot be empty"} | Empty body |
error | {"message": "Message too long..."} | Exceeds 200 chars |
state_update's chat field.Broadcast to every socket in a lobby's room whenever anything about the lobby changes: on join, on every player action, and — driven by a per-lobby background watcher greenlet — automatically on round timeout.
What drives a round forward
The same resolve_round() is also called synchronously from submit_choice the moment every alive, non-bot, non-spectator player has submitted — early resolution, no need to wait for the timer.
Round resolution — what resolve_round() does
- Sets
round_locked = Trueto prevent concurrent calls. - Runs the idle phase — players with no submission for >1 round are marked idle and skipped.
- Runs the resource phase —
gain_hprestores HP,gain_coinadds coins,gain_attackraises attack. - Runs the attack phase — attackers deal damage in well-winner-first order; defenders have a chance to block (and reflect); well-goers skip this phase.
- Runs the Well phase — a winner is drawn among the round's well-goers, gets a weighted reward (see Game Constants), and
wellwinneris set. - Checks game-end conditions via
get_winner(): one PvP survivor → game over; boss HP ≤ 0 → boss fight won. - On game end: persists final stats to Postgres (
games,game_player_stats,playerstables) — skipped if any bot is in the lobby. - Advances
round += 1, sets newround_end_time = now + 40s, clears per-round player state (submittedAction,submittedResource,messages,events). - Resets
round_locked = FalseandsubmittedBossMoves = False.
Payload schema
{
"round": 2,
"players": [ ...player objects... ],
"winner": null,
"wellwinner": null,
"pending_deny": null,
"deny_target": null,
"readyPlayers": ["Alice", "Bob"],
"history": ["Alice created a lobby.", "Bob joined.", "..."],
"round_end_time": "2026-01-01T12:00:40+00:00",
"boss_fight": false,
"start_time": "2026-01-01T12:00:00+00:00",
"gameover": false,
"chat": [ ...chat message objects... ]
}
Field reference
| Field | Type | Description |
|---|---|---|
round | integer | 0 = waiting in lobby; 1+ = game in progress. |
players | array | All player objects in the lobby (see Player Object below). |
winner | string | null | Name of the PvP/boss-fight winner once decided. null while ongoing. |
wellwinner | string | null | Name of the player who most recently won The Well. null otherwise. |
pending_deny | string | null | Name of the player who currently holds the deny ability and must pick a target this round. |
deny_target | string | null | Name of the player who was most recently denied their choices. Cleared each round. |
readyPlayers | string[] | Names of players who have submitted both action and resource this round. |
history | string[] | Ordered event log for the entire session. Human-readable strings, never cleared. |
round_end_time | ISO 8601 | UTC timestamp when the timer expires and auto-resolution fires. |
boss_fight | boolean | true for cooperative boss-fight lobbies. |
start_time | ISO 8601 | UTC timestamp when a scheduled boss fight begins. Not meaningful for PvP lobbies. |
gameover | boolean | true once the game has ended. |
chat | array | Lobby chat messages. Max 100 entries kept. |
Player object fields
submittedAction/submittedResource/target and the private messages/events/personal_history logs, since broadcasting those would leak a bluffing game's hidden information to opponents. It never includes session tokens either. Built by domain.player.Player.to_payload() off PUBLIC_PLAYER_FIELDS; the private fields are readable only by the player they belong to, via the token-gated /get_player_messages and /get_player_history HTTP routes above.
| Field | Type | Description |
|---|---|---|
name | string | Display name. |
hp | integer | Current hit points. |
coins | integer | Current coin count. |
attackDamage | integer | Current attack stat. |
alive | boolean | false once eliminated. |
admin | boolean | true for the lobby creator. |
spectator | boolean | true for observers with no actions. |
bot | boolean | true for AI dummy players and boss/lost-soul entities. |
boss | boolean | true for the boss-fight boss entity. |
lost_soul | boolean | null | true/false for a lost-soul entity, null for a regular player (never had the key at all pre-refactor — preserved as null, not false, so the wire shape didn't change). |
title | string | null | Cosmetic title, if any. |
idle_rounds | integer | Consecutive rounds without a submission. Players with idle_rounds > 1 are skipped in resolution. |
pending_relic_nudge | boolean | null | Drives a frontend nudge to claim a pending relic. Not itself a bluffing secret, so it's whitelisted even though it wasn't part of the original hidden-info pass. |
config.lobbies (an InMemoryLobbyRepository). It is not persisted to Postgres until the game ends. A server restart loses all active lobbies.
These are all one-off acknowledgements or error responses to a specific client action rather than broadcasts — each is documented next to the client → server event that triggers it. error always has the shape {"message": "..."}.
name + email pair matches the record stored in the players database table. Unregistered names are created on first use via /claim_name. Accounts can optionally require a one-time emailed code on every login (always_verify_email, toggled via the request/confirm-toggle endpoints above). CORS is controlled by CORS_ALLOWED_ORIGINS (wide open in dev, an explicit allowlist in prod).
helpers.issue_session_token the moment a player is added to a lobby (create_lobby, join_lobby, get_bossfight_lobby, the join_lobby socket event) and handed back to the client exactly once, in that call's response. Nothing after that point trusts a name/admin field from the request:
- HTTP routes that need to authorize access to one player's private data (
/get_player_messages,/get_player_history) take the token as?token=...and resolve it withhelpers.resolve_session_token. - Socket.IO connections present it once via the
join_roomevent, which bindsrequest.sidto a player name (helpers.bind_connection) for the lifetime of that connection; every later event on that connection derives the actor from the binding (helpers.get_actor), not from payload fields. - Kicking a player (
kick_player) revokes their token and connection binding immediately, so a kicked player's old token stops working right away instead of lingering.
helpers.py for the full mechanics.