Skip to main content

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. A lobby_code is the human-shareable join key; creator_id is who opened it; game_slug (FK to eligible_games.slug) is the bound game every member must record. quest_id (FK to quests.id, ON DELETE SET NULL) is the bound quest, set at creation via create_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_lobby rejects a quest that doesn't cover the lobby's game (QUEST_GAME_MISMATCH, checked against quest_games), and change_lobby_game keeps it only when the quest also covers the new game (quest_games), else clears it (EC-550). status ∈ (active [default], closed, expired); max_players is constrained 2–16 (default 16); expires_at bounds 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). Carries joined_at, their session_id once they start recording, and server_recording_started_at. This is where a lobby connects to the recording spine — a participant's session_id is the same id that appears in recording_sessions.
  • lobby_join_events — the full join/leave history, never deleted, driven by two triggers on lobby_participants: a join INSERTs a row (trg_lobby_join_history_insert, source default participant_insert); a leave does not add a row — trg_lobby_join_history_delete UPDATEs the newest open row's left_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 by igdb_id (the IGDB database id) with a slug and display_name. enabled is the on/off switch; tokens is 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 one igdb_id to many (store, store_id) pairs. store is the only Postgres enum in the schema: steam | epic | gog | microsoft | riot | rockstar | other. The igdb_id → eligible_games FK cascades on delete.
  • game_runtime_profiles — the capture recipe for a specific executable (exe_name) of a store entry: which engine it uses, a settings_override, and a settings_enforcement jsonb (the settings we force for clean capture; NOT NULL, default {} = "no enforcement"). The game_store_id FK cascades.
  • engine_defaults — the baseline settings_spec per engine, so a runtime profile only has to specify deltas. A profile's engine is an FK here.
Why so many tables for "a game"?

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 whose lobbies.quest_id is set (EC-549, refining EC-547's "any active lobby" rule). The binding travels with the party: the core learns it via POST /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_id NULL) 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:

installednot in non_gamesnot in blacklist_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.