Lobby & Catalog
Plain-language summary. Two related ideas live here. A lobby is a party of players recording together for one attempt, locked to a single game — it's how the "group of N" co-op recordings work. The catalog is the list of games we actually allow people to record, plus the technical details of how to capture each one. The two meet at one point: a lobby must pick a game from the catalog.
Map
Lobbies — the party
lobbies— one row per party. Alobby_codeis the human-shareable join key;creator_idis who opened it;game_slug(FK toeligible_games.slug) is the bound game every member must record.quest_id(FK toquests.id,ON DELETE SET NULL) is the bound quest, set at creation viacreate_lobby(p_game_slug, p_quest_id default null)and returned by every lobby-state RPC (join_lobby,get_lobby_status,get_my_active_lobby) so joiners learn it too — NULL means a casual party, which records under the free-play lane (EC-549). The binding is game-scoped:create_lobbyrejects a quest that doesn't cover the lobby's game (QUEST_GAME_MISMATCH, checked againstquest_games), andchange_lobby_gamekeeps it only when the quest also covers the new game (quest_games), else clears it (EC-550).status ∈ (active [default], closed, expired);max_playersis constrained2–16(default 16);expires_atbounds its life. A lobby is short-lived.lobby_participants— current membership, with composite PK(lobby_id, user_id)— that's what makes it the current roster (one live row per user per lobby). Carriesjoined_at, theirsession_idonce they start recording, andserver_recording_started_at. This is where a lobby connects to the recording spine — a participant'ssession_idis the same id that appears inrecording_sessions.lobby_join_events— the full join/leave history, never deleted, driven by two triggers onlobby_participants: a join INSERTs a row (trg_lobby_join_history_insert,sourcedefaultparticipant_insert); a leave does not add a row —trg_lobby_join_history_deleteUPDATEs the newest open row'sleft_at. So it's append-mostly: one row per join, stamped closed on leave.
The bound-game rule is the important invariant: the lobby fixes one game, and the
s3_uploads game-boundary trigger (see
Recording spine)
rejects any lobby-tagged upload whose game doesn't match. The
game_slug → eligible_games.slug FK is ON UPDATE CASCADE / ON DELETE RESTRICT:
a slug rename propagates into lobbies, but an eligible game backing a lobby
cannot be deleted. See Lobby Mode for the
full lifecycle and HTTP surface.
The game catalog
This is the most-normalized corner of the schema — four tables describe a game in layers, from "is it allowed" down to "how do we capture this specific executable."
eligible_games— the allowlist. Keyed byigdb_id(the IGDB database id) with asluganddisplay_name.enabledis the on/off switch;tokensis a jsonb of match tokens used by game-library detection to recognize a game from a scanned title. This is the table the Game Catalog docs are about, and the boundary every upload is validated against.game_store_ids— a game can be sold on several stores, so this maps oneigdb_idto many(store, store_id)pairs.storeis the only Postgres enum in the schema:steam | epic | gog | microsoft | riot | rockstar | other. Theigdb_id → eligible_gamesFK cascades on delete.game_runtime_profiles— the capture recipe for a specific executable (exe_name) of a store entry: whichengineit uses, asettings_override, and asettings_enforcementjsonb (the settings we force for clean capture;NOT NULL, default{}= "no enforcement"). Thegame_store_idFK cascades.engine_defaults— the baselinesettings_specper engine, so a runtime profile only has to specify deltas. A profile'sengineis an FK here.
Because "a game" is genuinely several things: a catalog entry (do we allow it?), a purchasable product (which store, which id?), and a runnable process (which exe, which engine, which capture settings?). Splitting them lets us add a store or an executable without touching the others — and lets capture rules be reused across every game on the same engine.
Recording lanes — quest vs free-play
The recorder runs each session in one of two lanes, decided at detection
time and persisted on the session row (see
recording_sessions.mode).
eligible_games is not a separate lane — it is a quest-enabling attribute
of a catalog game. Any installed game the player owns is a recordable free-play
target; being eligible additionally lets it satisfy a quest. (EC-475 / EC-481
remodel.)
- Quest. The game is in
eligible_games(so it can back a quest) and the player's party carries a quest binding — an active lobby whoselobbies.quest_idis set (EC-549, refining EC-547's "any active lobby" rule). The binding travels with the party: the core learns it viaPOST /api/lobby/set(quest_id) and gates the lane on it (LobbySnapshot::questBound()). Detection is scoped to the store id the launcher selected and the full settings-enforcement pipeline applies. - Free-play. Any installed game in the player's
user_game_library, recorded for its own value. Eligible games are included here too — an installed eligible title is a free-play target like any other (it just also unlocks its quest). Solo play and casual parties (active lobby,quest_idNULL) both land here — quest integrity applies exactly when a quest is actually attached. There is no catalog runtime profile for a non-eligible game, so settings-enforcement is skipped.
The eligibility rule for a free-play target is:
installed ∧ not in
non_games∧ not inblacklist_games
The two vetoes — non_games and blacklist_games — apply to both lanes: a
non-game or hard-blocked title is dropped even when it is an eligible_games
entry (blacklist beats whitelist). In the recorder this is enforced at the single
intake point every lane funnels through
(ProcessMonitor::addGame), with the free-play set additionally pre-filtered in
service/freeplay_targets.cpp::buildFreePlayTargets (shared + platform-neutral,
so the Windows and macOS detectors consume the same set). eligible_games still
wins an executable collision for the metadata (quest/store), and free-play
de-duplicates per-exe against it. Both vetoes are fail-safe: a transient fetch
failure keeps the last good set rather than recording a vetoed title.
non_games — the non-game veto
A small, global reference table of things that aren't games and must never be
surfaced/recorded even when installed — launchers, tools, dedicated servers, VR
runtimes, build artifacts. Each row carries a match_type:
store— a(provider, store_id)pair, matched exactly.name— a lowercased process / display name (name_lc).
It is populated by the supply-dashboard scrape. In the launcher grid these are
hidden entirely (they aren't games). Read-only to any authenticated client
(the recorder fetches it under the player's JWT, same auth as eligible_games).
blacklist_games — the game hard-block
The orthogonal counterpart to non_games: real, recordable games we
deliberately do not record (off-policy titles, e.g. League of Legends — see
FND-19). Keyed by the canonical igdb_id (one row bans a game across every
store it ships on); display_name / note are bookkeeping only. reason is
player-facing since EC-510: the launcher surfaces it as the one-line "why"
on blocked library cards. Prefer the reason codes the UI localizes —
anti_cheat, off_policy — over free text; unrecognized free text is shown
verbatim in the card tooltip only, never on the visible chip. The table
is intentionally not FK'd to eligible_games so a library game that was
never curated can still be banned. (EC-481.)
Distinct from eligible_games.enabled = false, which only means "not a curated
quest game" — such a title still falls through to free-play. blacklist_games is
a true ban across both lanes. In the launcher grid a blacklisted game stays
visible (the player owns it) but carries a "Can't record" chip with the
reason-derived one-liner (EC-510), is never quest-highlighted, and can't be
launched/recorded; the core refuses to record it regardless. Read-only to
any authenticated client, same as non_games.
user_game_library — what a player owns
Each player's game library, scanned from Playnite on their machine. The
surrogate PK is id; uniqueness is on (user_id, playnite_id) — not the
catalog link, so two store copies of the same IGDB game produce two rows.
synced_at drives the throttled re-sync. Each row (25 columns) carries the
provider / provider_game_id it came from, the name, install/usage flags
(is_installed, is_hidden, is_favorite), play stats (playtime_seconds,
play_count, last_played_at), platform / install_size_bytes /
release_date, rich metadata (genres, developers, publishers,
cover_image_url, executables — all jsonb), and crucially
game_registry_igdb_id, an FK back to eligible_games.igdb_id that links a
player's owned title to our catalog.
This is how we answer "which players own games we want recorded?" — joining
user_game_library to eligible_games on the IGDB id.
Access model
RLS is enabled on all eight tables here. The catalog tables
(eligible_games, game_store_ids, game_runtime_profiles, engine_defaults)
are read-only to any signed-in user. lobbies and lobby_participants are
participant-scoped; user_game_library is owner-scoped; lobby_join_events is
service-role-only (the CRM reads it via the service key).
See also: full column reference for these tables.