Skip to content

ADR-059: Multiplayer presence model and leave/rejoin UX

Status: Accepted (Phase 1) — Phases 2 & 3 deferred

Date: 2026-05-16

Context

Multiplayer sessions (quick rounds and campaign chapters) currently have no presence model. A "session" is a row in sessions with session_participants; once created it persists indefinitely regardless of whether anyone is connected. This produced concrete confusion in the May 15 playtest:

  • Jonathan created a Stonemark room, shared the code with Cici, then left to check something. The room stayed open, the code still worked, both rejoined later and continued. Good.
  • Jonathan asked: "if I left before anyone joined, would the game just collapse and no longer exist? Or would it stay static forever?" — we don't know, because there's no rule.
  • The hub now shows every multiplayer room a user ever touched as "saved." At scale this becomes noise. There's no concept of "this room is dead" vs. "this room is paused vs. someone might come back."
  • No host-migration: if the creator drops, the round can't be advanced (only the host clicks "Next Chapter").

Adjacent issues already addressed: - The chapter-doesn't-advance bug (ADR-tracker: fixed 2026-05-16 via auto-heal extension) - Invite link inconsistency (fixed 2026-05-16 by standardizing on /play/join/{code})

This ADR is specifically about who is present, what happens when they leave, and when does a room die.

At a glance

What it decides: Multiplayer rooms get a Jackbox-style presence model — a room is alive while someone is connected (or recently active within 24h), and if the host drops, the longest-connected remaining player is promoted. Phase 1 (presence tracking + host migration) is in force; the grace-window/abandoned-status UX (Phase 2) and code recycling (Phase 3) are deferred until hub clutter is actually painful.

  • Presence-based liveness room stays alive while ≥1 participant has an active Realtime subscription, OR it is under 24h old with recent activity (the async concession).
  • Host migration (Phase 1, live) if the creator's presence drops, the longest-connected remaining participant becomes host via current_host_id — removing the single point of failure on chapter-advance.
  • Grace windows (Phase 2, deferred) mid-game rooms pause 10 min before going abandoned; empty lobbies collapse after 60s.
  • Rejected: rooms-die-instantly and host-is-forever both produce the brittle failures already seen on Stonemark.
  • Watch if Phase 1 host-migration alone fixes the felt problem, Phases 2 & 3 may never ship.
stateDiagram-v2
    [*] --> waiting: "create room"
    waiting --> alive: "participant joins"
    waiting --> collapsed: "creator leaves, 60s grace, no joiners"
    alive --> alive: "host drops -> promote longest-connected (migrate)"
    alive --> paused: "last participant leaves (mid-game)"
    paused --> alive: "rejoin within 10 min"
    paused --> abandoned: "10 min grace elapses"
    alive --> complete: "campaign finished"
    collapsed --> [*]: "code recycles"
    abandoned --> [*]: "code recycles"
    complete --> [*]

Room lifecycle: alive vs. paused vs. abandoned, with host migration on host-drop and grace windows before collapse (grace/abandoned are Phase 2).

Decision

Adopt a Jackbox-style presence model, with one variation for the asynchronous nature of Sync (Sync rooms can outlive a single sitting; Jackbox rooms are real-time-only).

Presence semantics:

  1. Room is alive while ≥1 participant has an active Supabase Realtime presence subscription, OR the room is < 24h old AND has session activity within the last 24h. The "OR" branch protects async rooms where players step away overnight.
  2. Host migration: if the creator's presence drops and other participants are still present, promote the longest-connected remaining participant to host. Persist as current_host_id on sessions (new column).
  3. Empty-room grace: when the last present participant leaves a mid-game room, hold the room in a "paused" state for 10 minutes. If no one rejoins within the grace window, transition status to abandoned. If anyone rejoins, room resumes as if nothing happened.
  4. Pre-game collapse: if the only participant leaves a lobby (status: waiting) room with no other joiners, hold the room for a 60-second grace window, then collapse. Code recycles. The grace window covers the common "create room → close tab to text the code → friend clicks the link" pattern, which would otherwise punish a normal sharing flow. Shorter than the mid-game 10-minute window because a lobby with no joiners has nothing worth preserving long.
  5. Code lifetime: join_code stays valid as long as the room is alive. Once status transitions to abandoned or complete, the code recycles and a join attempt routes to "room not found."

Hub UX:

  • Split the "Your Campaigns" / "Your Rooms" lists into two buckets: Active (room alive, current_chapter not yet complete) and Past (completed or abandoned). Active rooms surface a "Resume" CTA; Past rooms are collapsed.
  • No explicit "leave" button needed for the resume-later use case — just close the tab. The grace window handles the rest.
  • Add an explicit "Leave campaign" action inside the campaign view for users who want to opt out permanently. This removes them from campaign_members (so they no longer see it under Active) without affecting the room for others.

Rationale

The Jackbox model is the closest cultural analog and players already understand it. The 24h-since-activity branch is the Sync-specific concession to async play — Sync isn't pure real-time, and our playtest evidence (Apr 22 Brink session ran across two days) shows rooms benefit from being more durable than a Jackbox room. The 10-minute grace window is short enough that a stuck-room cleanup happens quickly, but long enough that "I refreshed the page" doesn't kill the room.

Host migration is the single biggest unlock. Today, host-drop is a campaign-killer for the chapter-advance step. Migration removes a single point of failure without needing every participant to be a "co-host."

Alternatives considered

  • No grace window — rooms die the instant the last participant disconnects. Rejected: refresh-the-page kills the room, which is brittle and surprising.
  • No host migration — host is forever. Rejected: this is the failure mode we already saw on Stonemark.
  • Mark every multiplayer room as "active" forever. Status quo. Rejected: hub clutter, no signal about which rooms are actually playable.
  • Symmetric "all participants are hosts." Considered. Cleaner in some ways but loses the social ritual of "the host runs the room," and creates ambiguity around who fires the chapter-advance. Stick with host + migration.
  • Per-player "ready" toggle in the hub for resuming. Rejected as over-engineering; the resume CTA already implies readiness.

Consequences

Positive: - Hub stays clean as user count grows; clear distinction between "rooms I can still play" vs. "rooms that ended." - Host-drop no longer breaks campaigns mid-arc. - Refresh-the-page is no longer a failure case. - Code recycling means a finite namespace of 6-char codes stays meaningful at scale.

Negative / costs: - Requires wiring Supabase Realtime presence into the lobby + play page (not currently used for presence — only for broadcast events on lobby start). - Adds current_host_id, last_activity_at, and abandoned_at columns to sessions. Migration required. - Edge case: two participants both lose connection simultaneously inside the grace window. Whoever reconnects first becomes host; both rejoin participants. Acceptable. - Edge case: campaign-level abandonment (vs. session-level). A campaign's current_chapter session can be abandoned without abandoning the campaign — campaign stays Active, just no in-progress session. Need to handle "resume = create new session for current_chapter."

Migration / rollout: - Phase 1: presence tracking + host migration. No behavior change for users who don't lose connection. - Phase 2: grace-window + abandoned status. Hub UX split. - Phase 3: code recycling. Requires confirming no in-flight links during recycle window.

Key files (anticipated)

  • migrations/NNN_session_presence.sql — new columns on sessions, indexes on last_activity_at and status.
  • src/lib/multiplayer/presence.ts — new module wrapping Supabase Realtime presence per session.
  • src/app/play/[sessionId]/lobby/page.tsx — subscribe to presence on mount; trigger host-migration logic.
  • src/app/play/[sessionId]/page.tsx — same as lobby.
  • src/app/api/sessions/heartbeat/route.ts — new; client pings to update last_activity_at.
  • src/app/api/sessions/abandon/route.ts — new; explicit-leave endpoint.
  • src/app/(app)/hub/page.tsx (or wherever the campaign list lives) — Active/Past split.

Discussion

Cici and Jonathan discussed this on 2026-05-15 in the context of Stonemark's stuck-chapter bug. Jonathan's framing: "if I left before anyone joined, would the game just collapse and no longer exist? Or would it stay static forever? I don't know what the right UI UX is and what video games are doing these days." Followed by: "I'll follow your lead here."

The Jackbox reference was Jonathan's. The async-extension (24h activity window) is a Sync-specific concession driven by playtest evidence that rooms outlive single sittings.

Open question deferred: should campaign-level membership also have a leave/rejoin notion (vs. just session-level)? Today, joining a campaign session auto-adds to campaign_members; there's no leave path. Probably yes, but scope it as a separate ADR if needed — the presence model here is about session presence.

Trigger for revisit: if the Phase 1 host-migration alone resolves the felt UX problem, Phase 2+3 may not be needed. Don't ship Phase 2 unless the hub clutter is actually painful at the time.