ADR-075: Re-introduce curated multiplayer scenarios via an is_multiplayer gate¶
Status: Accepted Date: 2026-05-31 Supersedes: Earlier (informal) decision to defer curated multiplayer indefinitely
Context¶
Multiplayer sessions today (sessions.game_mode = 'multi_quick' | 'multi_campaign') source every scenario via live AI generation in src/app/api/sessions/start/route.ts:382-398. The curated lookup at line 346 falls through to AI for any multiplayer lobby because no row in the scenarios table was ever flagged as multiplayer-eligible.
Generation takes several minutes per call. Multiplayer lobbies decompose during that wait — players sit on a loading screen with no fallback. The "live AI as primary surface" approach is a UX-critical gap for multiplayer, captured in project-multiplayer-curated-corpus-gap and project-generation-latency-drives-curated-first.
We had previously decided informally not to maintain a separate curated multiplayer surface — the argument was that multiplayer is a smaller user base and curated content authored for solo would surface poorly in collective-vote contexts. That call was correct at the time but ignored the latency floor, which had not yet been measured against a real lobby flow.
The 8 hand-authored scenarios in multiplayer-curated-starter.md (designed across the scenario dimension cube, pinned to authority = collective-vote with named peer dynamics) are ready to ship and demonstrate the format.
At a glance
What it decides: Multiplayer sessions can now serve curated scenarios instantly via a single is_multiplayer boolean on the scenarios table — solo lobbies see solo rows, multiplayer lobbies see multiplayer rows, and live AI generation remains the fallback in both. This fixes the multi-minute loading wait that was breaking multiplayer lobbies.
- One boolean gate
is_multiplayer NOT NULL DEFAULT false; the session-start route derives it fromgame_mode.startsWith('multi_')and filters the existing curated lookup. - 8 starter scenarios shipped as
is_multiplayer = true, world-themed and pinned to collective-vote authority — they do NOT carrytheme = 'team'(that slot stays reserved for theteam_contextsubstrate flow). - Backward-compatible by default all 375+ existing curated rows stay solo-only; they were authored for individual-decision-maker authority.
- Rejected: theme overload, a separate table, and a
min_playerscolumn all rejected as conflating concepts or YAGNI; the boolean is the smallest viable change. - Watch lobbies outside the 8 covered (theme, category) cells still pay full AI latency — the fix is more curated rows; revisit the migration-file workflow if the corpus grows past ~30.
flowchart TD
L["Session start"] --> D{"game_mode.startsWith('multi_')?"}
D -->|"yes"| MP["Curated lookup WHERE is_multiplayer = true"]
D -->|"no"| SOLO["Curated lookup WHERE is_multiplayer = false"]
MP --> MH{"match?"}
SOLO --> SH{"match?"}
MH -->|"yes"| C["Serve curated (instant)"]
SH -->|"yes"| C
MH -->|"no"| AI["AI generation fallback"]
SH -->|"no"| AI
The `is_multiplayer` boolean gates the shared curated lookup by mode; AI generation remains the fallback when no curated row matches.
Decision¶
Add an is_multiplayer boolean NOT NULL DEFAULT false column to the scenarios table (migration 111). The session-start route reads session.game_mode, derives isMultiplayer = game_mode.startsWith('multi_'), and filters the curated lookup by that boolean. Solo sessions see only is_multiplayer = false rows; multiplayer sessions see only is_multiplayer = true rows. AI generation remains the fallback in both branches when no curated match is found.
The 8 starter scenarios are inserted with is_multiplayer = true, source = 'curated', themes set to their world themes (boardroom × 3, court × 2, rebuild, frontier, underground). They do NOT carry theme = 'team' — that slot remains reserved for the owner-curated team_context substrate flow.
Rationale¶
One small schema change, no new tables, no UI work. The infrastructure for curated scenarios (query, source filter, scan_strip and cast_block columns, banned-headers Variant D filter) already exists at lines 343-380 of the session-start route. The is_multiplayer boolean is the smallest surface change that gates multiplayer-only content without breaking solo behavior.
The gate is explicit, not implicit via theme. Tagging the 8 scenarios as theme = 'team' would have avoided the migration but would conflate two unrelated concepts: the 'team' theme is the owner-curated team_context substrate parallel (see src/lib/ai/scenario-generator.ts:559-587); the multiplayer-curated set is world-themed scenarios designed for collective vote. Keeping them in separate tagging dimensions preserves future flexibility — a 'team' scenario could also be is_multiplayer = true, or a multiplayer scenario could ship in any of the 5 world themes.
Backward compatibility is automatic. Default false means all existing 375+ curated rows stay solo-only, which is correct — they were written for individual-decision-maker authority, not collective-vote.
AI fallback preserved. When a multiplayer lobby picks a (theme, category) combination not yet covered by the 8 curated rows, generation still happens. Players get a scenario; latency may be high but no lobby breaks.
Alternatives considered¶
- Theme overload (
theme = 'team'). Rejected — conflates substrate-themed and world-themed scenarios; future filtering becomes ambiguous. - Add a generic
min_players intcolumn. Rejected as YAGNI — no requirement yet for tiered player-count gating (3+, 5+). Boolean is sufficient until that requirement actually arrives. - Separate
multiplayer_scenariostable. Rejected — duplicates schema, breaks the shared lookup path, complicates the AI fallback. Thescenariostable is already the right home. - Leave multiplayer on live AI only. Rejected — latency is the binding constraint; lobbies time out before generation completes. Curated is the only viable primary surface until either generation drops below ~30s per call or BYOM cost-transparent generation ships.
Consequences¶
Positive:
- Multiplayer lobbies in the 8 covered (theme, category) cells get instant curated scenarios.
- The is_multiplayer flag is the explicit, queryable answer to "is this scenario multiplayer-eligible?" — usable in admin tooling, analytics, and future filters.
- Sets the pattern for scaling the multiplayer corpus to 30 (the target per the starter doc's open questions).
Negative: - Multiplayer lobbies with theme/category combinations outside the 8 still pay the full AI-generation latency cost. The fix is more curated scenarios, not a generator change. - The boolean is binary — a future tiered player-count requirement would need a follow-up schema change.
Operational: - Migration 111 (column) + migration 112 (8 INSERTs) shipped together to keep behavior coherent: the gate column and the data it points to land in the same deploy. - No admin UI for adding curated multiplayer scenarios yet. Adding new rows is a migration-file workflow (one INSERT per scenario, like migration 112). Acceptable while the corpus is small; revisit if it grows past ~30.
Discussion¶
Why now and not earlier? The cube doc's project-multiplayer-curated-corpus-gap memo and the May 30 review session made the latency floor concrete: live AI was theatrical-on-paper but uninhabitable in a real lobby. The earlier "defer curated multiplayer" decision had pre-supposed that multiplayer would be a small enough surface that AI cost was acceptable; that pre-supposition was incompatible with the measured wait times.
Why a boolean instead of denormalizing into a view? A view would have been clean (multiplayer_scenarios view of scenarios WHERE is_multiplayer = true) but adds nothing the query parameter doesn't already give. The code at line 350 reads .eq('is_multiplayer', isMultiplayer) whether the column is direct or surfaced through a view; the direct column is one less indirection in admin tools.
What about the existing solo curated rows? They stay as-is. None of them are tagged with authority = collective-vote in their cube vectors (per the scenario corpus audit) so they wouldn't read correctly in multiplayer regardless. The is_multiplayer = false default preserves that.
Could a row be both? Schema allows it (boolean is per-row, not exclusive). Code requires it to be one or the other (.eq matches exactly). If we ever want a dual-mode scenario, we'd switch the filter to .in('is_multiplayer', [true, false]) or add a is_solo companion. No requirement yet.
Key files¶
migrations/111_scenarios_is_multiplayer.sql— schema changemigrations/112_multiplayer_starter_scenarios.sql— 8 INSERTs- src/app/api/sessions/start/route.ts (lines 343-365) — the gated lookup
- src/types/database.ts (line ~183) —
is_multiplayer?: booleanonScenario - docs/research/briefs/multiplayer-curated-starter.md — the 8 source scenarios with cube vectors and review metadata