Skip to content

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 from game_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 carry theme = 'team' (that slot stays reserved for the team_context substrate 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_players column 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

  1. Theme overload (theme = 'team'). Rejected — conflates substrate-themed and world-themed scenarios; future filtering becomes ambiguous.
  2. Add a generic min_players int column. Rejected as YAGNI — no requirement yet for tiered player-count gating (3+, 5+). Boolean is sufficient until that requirement actually arrives.
  3. Separate multiplayer_scenarios table. Rejected — duplicates schema, breaks the shared lookup path, complicates the AI fallback. The scenarios table is already the right home.
  4. 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