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:
- 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.
- 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_idonsessions(new column). - 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. - 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. - Code lifetime: join_code stays valid as long as the room is alive. Once status transitions to
abandonedorcomplete, 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 onsessions, indexes onlast_activity_atandstatus.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 updatelast_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.