feat: matchmaking lobby + invite links#97
Draft
laminair wants to merge 1 commit into
Draft
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: matchmaking lobby + invite links
Implements cross-user matchmaking. A user can host a game with one (or more) opponent slots deliberately left open, start their own LLM agent, and wait in the lobby. Other authenticated users discover the match either via a public lobby or an invite link, claim an open slot, and start their own agent. The game auto-starts once all open slots are filled.
Closes the problem described in the companion issue: a user can only ever play against agents they coordinate with out-of-band today. This PR adds the missing discover + join surface while keeping the existing single-user flows intact.
Scope
In scope
publicvisibility mode for sessions with one or moreopen_slots.invite_codeis generated for public matches; a landing page at/lobby/invite/{code}lets an authenticated user claim a slot.participant_user_idsmembers (or holders of a validnks_token) can read them.create_open_match,join_match,list_open_matches,get_match, and await_for_opponent()helper.Out of scope
Approach
Data model
New Alembic migration. Defaults keep all existing rows compatible.
backend/arena/models/session.py:11-30— extendSessionModel:visibilityString(16)"private""private"(existing) or"public"(matchmaking)open_slotsJSONB(list ofstr)[]["B"]or["B","C"]invite_codeString(16) UNIQUE NULLNULLparticipant_user_idsJSONB(list ofstrUUIDs)[]slot_ownersJSONB(mapplayer_id -> user_id){}SessionModel.user_idremains the host/creator.participant_user_idsis the new authoritative "is this user in the match" set for history queries.Backend endpoints
POST /api/experiment(main.py:431-480) — extended:visibility: "private"|"public",open_slots: [str],invite_code: str (optional).open_slotsreferences real player IDs from the game config;visibility="public"requiresopen_slotsnon-empty; generate a fresh 10-charinvite_codeif absent andvisibility="public".nks_token is only generated for slots filled at creation. Open slots get their token when filled.save_newsets the new columns.GET /api/lobby/matches(new) —main.pynear the catalog endpoints (around line 354):game: str | None,limit: int = 50,offset: int = 0.[{session_id, game_slug, invite_code, host_display_name, host_agent_name, num_open_slots, total_slots, game_config_summary, created_at}].visibility="public",open_slots != [],status="ready",created_atwithin the last N hours.GET /api/lobby/matches/{invite_code}(new) — same as above but for one match.POST /api/sessions/{id}/join(new) —main.pynearDELETE /api/sessions/{id}(line 1010):require_user; reject ifuser.id == session.user_id.session_idorinvite_codein the URL.{agent_id, agent_name, agent_config?}describing the joiner's remote LLM agent.UPDATE ... WHERE open_slots != '[]'): pops the first player ID fromopen_slots, appendsstr(user.id)toparticipant_user_ids, setsslot_owners[player_id] = str(user.id), updatesagents_json[player_id], generates a newnks_key, and — ifopen_slotsis now empty — setsstatus = "running".session.updatedover the Redis broker.{session_id, player_id, player_token, game_config, status}. On race-loss: 409.DELETE /api/sessions/{id}(main.py:1010-1029) — extended:visibility="public",status="ready", no one has joined: host may cancel; clears the lobby entry.GET /api/sessions(main.py:955-1007) andGET /api/dashboard(main.py:1032-1073) — relax filter touser_id == me OR participant_user_ids @> [me]. Add a derivedrole: "host"|"opponent"field.GET /api/session/{id}/state(main.py:483-495) andGET /api/session/{id}/observation(main.py:498-516) — privacy hardening: caller must be inparticipant_user_idsOR hold a validnks_token for the session, else 403. This closes the URL-leak hole.Game-start semantics
Add a small helper
await_session_ready(session_id, player_id)that returnsFalseuntilstatus="running". The host'sBaseAgentcallswait_for_opponent()before its firstsubmit_action(); the helper pollsGET /api/session/{id}/summaryevery 2s.Match expiry
Add
MATCHMAKING_TTL_HOURS=24tobackend/arena/config.py. A lifespan task on a 1-hour interval:Agent SDK
agent-sdk/src/outplayarena_sdk/client.py— add toArenaClient(alongsidecreate_experimentatclient.py:60-115):agent-sdk/src/outplayarena_sdk/base.py(BaseAgent.run, aroundbase.py:82-217): aftercreate_open_match, callawait self.client.wait_for_opponent(session_id)ifopen_slotswas non-empty; then proceed with the normal game loop.Add a new
MatchmakerAgentexample underagent-sdk/src/outplayarena_sdk/agents/matchmaking/.agent-sdk/src/outplayarena_sdk/quick_play.py— leave untouched.Frontend
LobbyPage(frontend/src/pages/LobbyPage.tsx): cards with game, host display name, host agent, # open slots, total slots, rounds, time-since-created, Join button. Filters: game dropdown, "Hide full" toggle. Polls every 10s. "My active matches" section at top (sessions where I'm host or joiner withstatus in ("ready","running"))./lobby/invite/{inviteCode}route →LobbyInvitePage: shows match summary, Claim slot & start agent button. After auth, callsjoinMatch()and redirects to the play view with the returnedplayer_tokenstored instate.pendingGame.LobbyConfigShell(frontend/src/components/LobbyConfigShell.tsx:24-194): new "Open for matchmaking" toggle near the Start button. When on, the open slots'PlayerSetupCards are replaced with a disabled "Waiting for opponent" card. A "Make this match public" checkbox is on by default. On submit, surface the returnedinvite_code+ a Copy link button.AutoConfigFormalready exposes the fullconfig_schema, so no new config UI is needed.WaitingForOpponentPage(new, or a "waiting" tab inGamePlayPage): shows invite link + copy, polls for opponent joining, then switches to the play view.GamePlayView(frontend/src/components/GamePlayView.tsx:37-595): new header badgeHosting (waiting)/Joined/Livebased onstatusandrole.api.tsandstate.tsx(frontend/src/state.tsx:29-86, 156-161): new wrappers andpendingGamefields forvisibilityandinvite_code.NavBar(frontend/src/components/NavBar.tsx:1-170): top-level Lobby link with a small badge for the number of open matches in the user's games.Auth & local-mode handling
The current local-mode sentinel
LOCAL_USER_ID(auth/dependencies.py:15-117) means there's only one effective user — matchmaking is meaningless. Add a siblingrequire_multiplayer_userdependency that raises 403 in local mode; use it on/api/lobby/*andPOST /api/sessions/{id}/join. The lobby UI shows a banner: "Multiplayer matchmaking requires GitHub or Google sign-in." The matchmaking toggle inAutoConfigFormis disabled with a tooltip.Display names & privacy
For lobby entries, do not expose the host's
user_idornks_key. The lobby API returns only:host_display_name(fromUser.nameor fallback"anonymous-{short_id}")host_agent_name(the string the host put in theirPlayerSetupCard)Same for the join view: when a joiner is shown to a host, only display name + agent name.
Phased implementation
I recommend shipping in this order so each step is independently demoable.
Phase 1 — Backend matchmaking core (~1.5d)
GameSession.createandsave_new(session.py:111-126).GET /api/lobby/matches,GET /api/lobby/matches/{invite_code},POST /api/sessions/{id}/join.POST /api/experimentforvisibility/open_slots/invite_code.GET /api/sessionsandGET /api/dashboardto include joined sessions.GET /api/session/{id}/stateand/observationwith participant check.Phase 2 — Agent SDK support (~0.5d)
ArenaClient.create_open_match,join_match,list_open_matches,get_match.wait_for_opponent()helper.matchmakingexample agent inagent-sdk/examples/.Phase 3 — Frontend lobby + create toggle (~1.5d)
LobbyPagewith cards, filters, polling./lobby/invite/{code}landing page.LobbyConfigShell/AutoConfigForm.GamePlayViewrole badge.Phase 4 — Polish (~0.5d)
docs/explaining matchmaking flow + SDK snippet.File touch list
Backend
backend/arena/models/session.py:11-30— new columnsbackend/arena/session.py:111-126—create/save_newsignaturebackend/arena/main.py:431-480—POST /api/experimentbackend/arena/main.py:354-411— addlobbyendpoints nearbybackend/arena/main.py:483-516— participant check onstate/observationbackend/arena/main.py:955-1073—sessions&dashboardfiltersbackend/arena/main.py:1010-1029— delete + cancel semanticsbackend/arena/auth/dependencies.py:111-117— addrequire_multiplayer_userbackend/arena/config.py—MATCHMAKING_TTL_HOURSbackend/migrations/versions/— new migrationAgent SDK
agent-sdk/src/outplayarena_sdk/client.py:60-115— new methodsagent-sdk/src/outplayarena_sdk/base.py:82-217—wait_for_opponentagent-sdk/src/outplayarena_sdk/agents/matchmaking/— exampleFrontend
frontend/src/pages/LobbyPage.tsx— newfrontend/src/pages/LobbyInvitePage.tsx— newfrontend/src/pages/WaitingForOpponentPage.tsx— newfrontend/src/components/LobbyConfigShell.tsx:24-194— togglefrontend/src/components/GamePlayView.tsx:37-595— role badgefrontend/src/components/NavBar.tsx:1-170— Lobby linkfrontend/src/api.ts:65-72— new wrappersfrontend/src/state.tsx:29-86, 156-161—pendingGamefieldsTest plan
POST /api/sessions/{id}/joincalls: exactly one returns 200, the other 409.POST /api/sessions/{own_id}/joinreturns 403/409.GET /api/session/{id}/statereturns 403."ready",open_slots=["C"].status="failed"by the sweeper.create_open_matchreturns a token usable insubmit_action.wait_for_opponentresolves when status flips torunning.join_matchworks with eithersession_idorinvite_code./api/lobby/matchesreturns 404.