World of Mythos Backend

API Reference — Flask (REST) + Flask-SocketIO (real-time gameplay)

Two transports. Account, lobby-creation, inventory and artifact actions are plain HTTP endpoints. Once a player is inside a lobby, all gameplay — joining the room, submitting actions, chat, round resolution broadcasts — happens over a persistent Socket.IO connection. There is no polling endpoint for game state; the server pushes state_update whenever anything changes.
This page is a human-friendly tour. 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.
Session tokens. Joining a lobby (/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.
Any HTTP route can also return 500 {"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.
Authentication (HTTP)
POST /claim_name Register or update a player name

Registers a new player name linked to an email, or attaches an email to a name that was previously unclaimed.

Request Body

FieldTypeRequiredDescription
namestringYes3–12 characters, no spaces or special characters
emailstringYesValid email address

Responses

StatusBodyMeaning
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
Rate limited: 10/min per IP, 5/min per name.
POST /check_name Check whether a name is already claimed

Lets the frontend distinguish "free name" from "needs login" before showing the claim/login form.

Request Body

FieldTypeRequiredDescription
namestringYesName to check

Responses

StatusBodyMeaning
200{"claimed": true}Name is linked to an email
200{"claimed": false}Name is free or unclaimed
400{"error": "Name is required."}Missing name
POST /log_in Verify name + email credentials

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

FieldTypeRequiredDescription
namestringYesRegistered name
emailstringYesAssociated email

Responses

StatusBodyMeaning
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
Rate limited: 10/min per IP, 5/min per name.
POST /verify_code Consume a login verification code

Completes login for accounts with always_verify_email enabled. Codes expire after 10 minutes and allow 5 attempts before being invalidated.

Request Body

FieldTypeRequiredDescription
namestringYesRegistered name
codestringYes6-digit code sent by email

Responses

StatusBodyMeaning
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
POST /get_always_verify_email_flag Read a player's always-verify setting

Request Body

FieldTypeRequiredDescription
namestringYesRegistered name
emailstringYesMust match the stored email

Responses

StatusBodyMeaning
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
POST /request_toggle_verify_email Email a confirmation link to flip always-verify

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

FieldTypeRequiredDescription
namestringYesRegistered name
emailstringYesMust match the stored email
always_verify_emailbooleanYesValue to apply once confirmed

Responses

StatusBodyMeaning
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
Tokens expire after 30 minutes. Requesting a new link invalidates any earlier pending one for the same name. Rate limited: 5/min per IP, 3/min per name.
POST /confirm_toggle_verify_email Consume the token from the confirmation link

Request Body

FieldTypeRequiredDescription
tokenstringYesToken from the emailed link

Responses

StatusBodyMeaning
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
Lobby (HTTP)
POST /create_lobby Create a new PvP lobby

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

FieldTypeRequiredDescription
namestringYesPlayer name (validated against DB)
emailstringYesPlayer email (must match DB record, if claimed)

Responses

StatusBodyMeaning
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
Lobby IDs are 4 characters from an uppercase-letter + digit alphabet (~1.68M combinations), checked for collisions against live lobbies.
POST /join_lobby/<lobby_id> Join an existing lobby (pre-check)

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

FieldTypeRequiredDescription
namestringYesPlayer name
emailstringYesPlayer email

Responses

StatusBodyMeaning
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.

GET /get_history/<lobby_id> Full event log for a lobby

Responses

StatusBodyMeaning
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.
GET /get_player_messages/<lobby_id>/<player_name>?token=... Private messages + structured events for a player this round

Private to the player named — requires a session token (query param) that resolves to player_name. See Session Tokens.

Responses

StatusBodyMeaning
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.
GET /get_player_history/<lobby_id>/<player_name>?token=... Player's personal round-by-round history

Same token authorization as /get_player_messages above.

Responses

StatusBodyMeaning
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
Boss Fight (HTTP)
POST /get_bossfight_lobby Join or create the shared, scheduled boss-fight lobby

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

FieldTypeRequiredDescription
namestringYesPlayer name (must exist in DB)

Responses

StatusBodyMeaning
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
Hades bosses: HP=8, DMG=2  |  Regular bosses: HP=30, DMG=4  |  Scheduled interval: 1 minute
GET /get_next_bossfight_time Scheduled start time of the next boss fight
{ "start_time": "<ISO 8601 datetime>" }
If the current boss fight has timed out (>2 min with no activity), it is marked over and a new one is scheduled. Assumes the bossfight lobby always exists (ensure_bossfight_lobby()) — there is no "lobby not found" branch.
POST /get_player_relics Fetch relics collected by a player

Request Body

FieldTypeRequiredDescription
namestringYesPlayer name

Responses

StatusBodyMeaning
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

POST /claim_pending_relic Claim a relic won by a player without an account yet

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

FieldTypeRequiredDescription
namestringYesPlayer name
emailstringYesEmail to attach if the account is new
lobby_idstringYesThe boss-fight lobby the relic was won in

Responses

StatusBodyMeaning
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
Artifacts
Replaces the former Vault section. /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.
POST /artifacts/ledger Every artifact ever discovered, oldest first

Request Body

FieldTypeRequiredDescription
tokenstringYesAccount session token
afterintNoKeyset cursor on ordinal (default 0)
limitintNoPage size, default 100, max 200

Responses

StatusBodyMeaning
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
Readable only by an account that has discovered an artifact — the ledger is part of the reward, so it is gated on owning a row rather than merely holding a session, and it is gated on the server rather than only in the UI. Clients should render the 403 as “sealed”, not as an error. No player ids, emails or claim state are returned even to an entitled reader: a name and a date is the whole record.
POST /claim_pending_artifact Claim an artifact discovered without an account

Request Body

FieldTypeRequiredDescription
namestringYesPlayer name that made the discovery
emailstringYesEmail to bind the artifact to
lobby_idstringYesLobby the discovery happened in

Responses

StatusBodyMeaning
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
Same shape as /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.
POST /inventory/equip_cosmetic Equip or unequip the artifact cosmetic

Request Body

FieldTypeRequiredDescription
tokenstringYesAccount session token
cosmeticstringYesCosmetic id, or "" to unequip

Responses

StatusBodyMeaning
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
A cosmetic is worn alongside the skin, not instead of it — so this is a separate field from /inventory/equip. Unequipping needs no ownership check: taking something off is always allowed.
Health
GET /healthz Deploy-host liveness/readiness check

No auth, no request body. Runs a SELECT 1 against the database.

Responses

StatusBodyMeaning
200{"status": "ok"}DB reachable
503{"status": "error"}DB connection failed (logged with traceback server-side)
Socket.IO Events — Client → Server
WS join_lobby Socket-native equivalent of POST /join_lobby

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

FieldTypeDescription
lobby_idstringTarget lobby
namestringPlayer name
emailstringPlayer email

Emits back

EventPayloadMeaning
joined_lobby{"lobby_id": "...", "token": "..."}Success — the token is handed back exactly once, here
error{"message": "..."}Name taken / invalid / lobby not found
Unlike 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).
WS join_room Enter the Socket.IO room and start receiving live state ★ Key event

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

FieldTypeDescription
lobby_idstringTarget lobby
tokenstringSession token from join_lobby or an HTTP join route

Emits back

EventPayloadMeaning
joined{"lobby_id": "...", "name": "..."}Room joined
state_updatesee belowCurrent 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
WS leave_room Leave the Socket.IO room (does not remove the player)

Payload

FieldTypeDescription
lobby_idstringLobby to leave

Emits back

EventPayload
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).

WS start_game Admin starts the game, advancing to round 1

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

FieldTypeDescription
lobby_idstringTarget lobby

Emits back

EventPayloadMeaning
state_updatesee belowBroadcast 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
WS kick_player Admin removes a player before round 2

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

FieldTypeDescription
lobby_idstringTarget lobby
targetstringName of player to kick

Emits back

EventPayloadMeaning
state_updatesee belowBroadcast; 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
WS add_dummy Admin adds an AI bot to the lobby

The caller's identity is derived from this connection's join_room binding, not from the payload.

Payload

FieldTypeDescription
lobby_idstringTarget lobby

Emits back

EventPayloadMeaning
state_updatesee belowBroadcast; 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
Bot type is picked randomly from config.BOT_TYPES (currently just TURTLE, a defend/heal-only bot).
WS submit_choice Submit action + resource for the current round

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

FieldTypeDescription
lobby_idstringTarget lobby
actionstring | nullattack, defend, or well
resourcestring | nullgain_hp, gain_coin, or gain_attack
targetstring | nullTarget player name (required — and validated as alive/non-spectator/not-self — only when action is attack)

Emits back

EventPayloadMeaning
state_updatesee belowBroadcast 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
WS submit_deny_target Choose who to deny after winning the deny_choice reward

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

FieldTypeDescription
lobby_idstringTarget lobby
targetstringPlayer to deny

Emits back

EventPayloadMeaning
state_updatesee belowBroadcast; pending_deny cleared
error{"message": "Invalid"}Caller is not the pending denier
WS send_message Send a lobby chat message

The sender is derived from this connection's join_room binding, not from the payload.

Payload

FieldTypeDescription
lobby_idstringTarget lobby
messagestringMessage content (max 200 characters)

Emits back

EventPayloadMeaning
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
The full history (up to the last 100 messages) is also available via state_update's chat field.
Socket.IO Events — Server → Client
WS state_update Full lobby state — the heartbeat of every active game session ★ Key event

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

Background watcher (one greenlet per lobby, started by start_game / join_room) │ ├─ Boss-fight pre-game (boss_fight=true, round=0) │ Sleep until start_time │ round = 1, round_end_time = now + 40s │ Broadcast state_update │ └─ Main loop (every lobby, round >= 1) Sleep until round_end_time If NOT round_locked: trigger_ai_moves(lobby) → bots + boss submit their moves resolve_round(lobby_id) → advances the game Broadcast state_update Else: Skip (submit_choice already resolved this round)

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

  1. Sets round_locked = True to prevent concurrent calls.
  2. Runs the idle phase — players with no submission for >1 round are marked idle and skipped.
  3. Runs the resource phasegain_hp restores HP, gain_coin adds coins, gain_attack raises attack.
  4. 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.
  5. Runs the Well phase — a winner is drawn among the round's well-goers, gets a weighted reward (see Game Constants), and wellwinner is set.
  6. Checks game-end conditions via get_winner(): one PvP survivor → game over; boss HP ≤ 0 → boss fight won.
  7. On game end: persists final stats to Postgres (games, game_player_stats, players tables) — skipped if any bot is in the lobby.
  8. Advances round += 1, sets new round_end_time = now + 40s, clears per-round player state (submittedAction, submittedResource, messages, events).
  9. Resets round_locked = False and submittedBossMoves = 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

FieldTypeDescription
roundinteger0 = waiting in lobby; 1+ = game in progress.
playersarrayAll player objects in the lobby (see Player Object below).
winnerstring | nullName of the PvP/boss-fight winner once decided. null while ongoing.
wellwinnerstring | nullName of the player who most recently won The Well. null otherwise.
pending_denystring | nullName of the player who currently holds the deny ability and must pick a target this round.
deny_targetstring | nullName of the player who was most recently denied their choices. Cleared each round.
readyPlayersstring[]Names of players who have submitted both action and resource this round.
historystring[]Ordered event log for the entire session. Human-readable strings, never cleared.
round_end_timeISO 8601UTC timestamp when the timer expires and auto-resolution fires.
boss_fightbooleantrue for cooperative boss-fight lobbies.
start_timeISO 8601UTC timestamp when a scheduled boss fight begins. Not meaningful for PvP lobbies.
gameoverbooleantrue once the game has ended.
chatarrayLobby chat messages. Max 100 entries kept.

Player object fields

This is a whitelist, not the full player record. Every connection in the lobby sees every player's entry here — so it deliberately excludes this round's not-yet-revealed 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.
FieldTypeDescription
namestringDisplay name.
hpintegerCurrent hit points.
coinsintegerCurrent coin count.
attackDamageintegerCurrent attack stat.
alivebooleanfalse once eliminated.
adminbooleantrue for the lobby creator.
spectatorbooleantrue for observers with no actions.
botbooleantrue for AI dummy players and boss/lost-soul entities.
bossbooleantrue for the boss-fight boss entity.
lost_soulboolean | nulltrue/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).
titlestring | nullCosmetic title, if any.
idle_roundsintegerConsecutive rounds without a submission. Players with idle_rounds > 1 are skipped in resolution.
pending_relic_nudgeboolean | nullDrives 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.
In-memory only. All lobby data lives behind config.lobbies (an InMemoryLobbyRepository). It is not persisted to Postgres until the game ends. A server restart loses all active lobbies.
WS error / joined / joined_lobby / left / chat_message Smaller server → client events, covered inline above

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": "..."}.

Game Constants
ROUND_DURATION
40s
Time per round before auto-resolve
HADES_HP
8
Hades boss hit points
HADES_DMG
2
Hades boss damage per round
BOSS_HP
30
Regular (non-Hades) boss hit points
BOSS_DMG
4
Regular (non-Hades) boss damage per round
BOSSFIGHT_INTERVAL
1 min
Time between scheduled boss fights
Lobby ID length
4
Uppercase letters + digits (~1.68M combinations)
Session Tokens
Account identity (separate from in-lobby session tokens, below) works by verifying that the supplied 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).
In-lobby identity is a separate, opaque per-player session token, minted by 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 with helpers.resolve_session_token.
  • Socket.IO connections present it once via the join_room event, which binds request.sid to 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.
See helpers.py for the full mechanics.