Add 2v2 ranked matchmaking#4596
Conversation
Integrates the 2v2 queue from the matchmaking API (API PR #419) across core, server, and client. Core (deterministic team pinning): - PlayerSchema/PlayerInfo gain an optional teamIndex - a server-stamped slot into the game's team list. It feeds deterministic team assignment like clanTag/friends, identical for every client. - assignTeams honors pins unconditionally (before clan/friend grouping and past maxTeamSize - the matcher's balancing is authoritative) and seeds the counts the remaining balancing sees. Server: - One checkin long-poll loop per mode; 1v1 still omits the mode field so an API without mode support keeps working. - get2v2Config: Team mode, 2 teams, 4 players, donations on. No rankedType - ingestion still only accepts "1v1", so 2v2 games are reported like ordinary team games until 2v2 ingestion ships. - The assignment payload is now used: players -> allowedPublicIds (only the matched accounts can take the slots, also hardening 1v1), teams -> teamIndex stamped per client at game start via publicId lookup. - The 3-clients-per-IP cap is skipped for identity-gated (allowlisted) games: it added nothing on top of the account allowlist and locked out legitimate same-NAT players the matcher grouped together. Client: - Ranked modal's 2v2 card is enabled and passes mode through open-matchmaking; the matchmaking modal joins with mode=2v2 (omitted for 1v1), shows a 2v2 title, and shows "No ELO yet" until 2v2 ingestion ships. Harnesses: - Contained: fake server captures the mode param; asserts 1v1 omits it and 2v2 sends it. - E2E: MM_MODE=2v2 runs four browser players through the real queue and into the started game, asserting the 2v2 config, allowlist admission, and a deterministic 2 vs 2 in-game split (WebGL software gate spoofed in test pages only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughMatchmaking now supports explicit 1v1 and 2v2 modes. The server creates mode-specific games, carries assigned team slots to clients, and applies pinned team assignments. Tests cover queue payloads, game setup, allowlists, and deterministic 2v2 teams. ChangesMatchmaking mode flow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Player
participant MatchmakingModal
participant Worker
participant GameManager
participant GameServer
participant GameClient
Player->>MatchmakingModal: select 1v1 or 2v2
MatchmakingModal->>Worker: join mode-specific queue
Worker->>GameManager: create configured game
GameManager->>GameServer: pass identities and team groups
GameServer-->>GameClient: send game assignment
GameClient->>GameServer: join assigned game
GameServer-->>GameClient: start game with teamIndex values
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The 1v1/2v2 cards only scaled 1.02 on hover, which barely reads. Adopt the quick-action card hover treatment (brightness bump + blue glow shadow + scale) with a stronger brightness since the cards are large and dark. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/server/Worker.ts (1)
756-758: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winAvoid logging the full matchmaking response payload.
datacan includeassignment.players(matched players' account public IDs). Logging the whole object at info level puts stable user identifiers into logs unnecessarily; log only what's needed to confirm a successful poll (e.g., whether an assignment was present).🔒 Suggested redaction
- const data = await response.json(); - log.info(`Lobby ${mode} poll successful:`, data); + const data = await response.json(); + log.info(`Lobby ${mode} poll successful, assignment present: ${!!data.assignment}`);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/Worker.ts` around lines 756 - 758, Update the successful poll logging in Worker’s response handling to stop passing the full data payload to log.info. Log only a non-sensitive success indicator, such as whether data.assignment is present, while preserving the existing poll success message.src/server/GameServer.ts (1)
543-548: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse
hasJoinWhitelist()instead of re-deriving the same condition.
identityGatedduplicates the exact expression already encapsulated inhasJoinWhitelist()(Lines 1294-1296). Calling the existing method keeps both call sites in sync if the "what counts as identity-gated" definition ever changes.♻️ Suggested fix
- const identityGated = (this.gameConfig.allowedPublicIds?.length ?? 0) > 0; + const identityGated = this.hasJoinWhitelist();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/GameServer.ts` around lines 543 - 548, Update the identityGated assignment in the matchmade-game same-IP-cap logic to call the existing hasJoinWhitelist() method instead of directly checking allowedPublicIds.length. Preserve the surrounding conditional behavior while reusing that method as the single source of truth.src/server/MapPlaylist.ts (1)
417-449: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract shared ranked-map-selection logic from
get1v1Config/get2v2Config.The
mapsarray,isCompactroll, andmaxTimerValue/difficulty logic here are byte-for-byte duplicates ofget1v1Config(Lines 386-415). Any future tweak to the ranked map pool now has to be kept in sync by hand in two places.♻️ Suggested extraction
+ private pickRankedMapAndCompact(): { map: GameMapType; isCompact: boolean } { + const maps = [ + GameMapType.Australia, // 40% + GameMapType.Australia, + GameMapType.Iceland, // 20% + GameMapType.Asia, // 20% + GameMapType.EuropeClassic, // 20% + ]; + return { + map: maps[Math.floor(Math.random() * maps.length)], + isCompact: Math.random() < 0.2, + }; + } + public get1v1Config(): GameConfig { - const maps = [...]; - const isCompact = Math.random() < 0.2; + const { map, isCompact } = this.pickRankedMapAndCompact(); return { ... - gameMap: maps[Math.floor(Math.random() * maps.length)], + gameMap: map, ... } satisfies GameConfig; } public get2v2Config(): GameConfig { - const maps = [...]; - const isCompact = Math.random() < 0.2; + const { map, isCompact } = this.pickRankedMapAndCompact(); return { ... - gameMap: maps[Math.floor(Math.random() * maps.length)], + gameMap: map, ... } satisfies GameConfig; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/MapPlaylist.ts` around lines 417 - 449, Extract the duplicated ranked map-selection setup from get1v1Config and get2v2Config into a shared helper or reusable symbol. Reuse it in both methods for the maps array, isCompact roll, selected gameMap, maxTimerValue, and difficulty values, while preserving each method’s distinct player-count and game-mode configuration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/server/GameServer.ts`:
- Around line 543-548: Update the identityGated assignment in the matchmade-game
same-IP-cap logic to call the existing hasJoinWhitelist() method instead of
directly checking allowedPublicIds.length. Preserve the surrounding conditional
behavior while reusing that method as the single source of truth.
In `@src/server/MapPlaylist.ts`:
- Around line 417-449: Extract the duplicated ranked map-selection setup from
get1v1Config and get2v2Config into a shared helper or reusable symbol. Reuse it
in both methods for the maps array, isCompact roll, selected gameMap,
maxTimerValue, and difficulty values, while preserving each method’s distinct
player-count and game-mode configuration.
In `@src/server/Worker.ts`:
- Around line 756-758: Update the successful poll logging in Worker’s response
handling to stop passing the full data payload to log.info. Log only a
non-sensitive success indicator, such as whether data.assignment is present,
while preserving the existing poll success message.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2082b801-bf5d-4c20-bba3-dcd38391cb49
📒 Files selected for processing (18)
resources/lang/en.jsonsrc/client/Main.tssrc/client/Matchmaking.tssrc/client/components/RankedModal.tssrc/core/GameRunner.tssrc/core/Schemas.tssrc/core/game/Game.tssrc/core/game/TeamAssignment.tssrc/server/GameManager.tssrc/server/GameServer.tssrc/server/MapPlaylist.tssrc/server/Worker.tstests/Team.test.tstests/TeamAssignment.test.tstests/matchmaking/README.mdtests/matchmaking/contained.mjstests/matchmaking/e2e.mjstests/matchmaking/fakeServer.mjs
Use the solo button palette (bg-malibu-blue, hover:bg-aquarius, active:bg-malibu-blue/80) on the 1v1/2v2 cards, keeping the hover glow/scale. Subtitle opacity bumped to white/80 for readability on the brighter background. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API now supports 2v2: ingestion accepts rankedType "2v2" and /users/@me returns a twoVtwo leaderboard row. Unblocks the pieces that were deliberately deferred: - RankedType gains TwoVTwo; get2v2Config sets it, so 2v2 games are ingested as ranked 2v2 instead of ordinary team games. - UserMeResponse schema gains leaderboard.twoVtwo; the matchmaking and ranked modals show the real 2v2 ELO instead of "No ELO yet". - WinModal shows requeue for any ranked game and carries the mode (/?requeue=2v2); Main routes it back into the right queue. - Player stats tree labels 2v2 ranked stats (they surface automatically now that the enum value exists). - e2e 2v2 harness asserts the game config carries rankedType "2v2". - docs/API.md rankedType filter now lists 2v2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/server/MapPlaylist.ts (1)
417-447: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting shared logic between
get1v1Config()andget2v2Config().Both methods pick from the same map pool, roll
isCompactthe same way, and reuse the samemaxTimerValue/spawnImmunityDurationvalues. Factoring the common parts into one small private helper would keep the two configs from drifting apart as either mode evolves.♻️ Suggested refactor sketch
- public get1v1Config(): GameConfig { - const maps = [ - GameMapType.Australia, // 40% - GameMapType.Australia, - GameMapType.Iceland, // 20% - GameMapType.Asia, // 20% - GameMapType.EuropeClassic, // 20% - ]; - const isCompact = Math.random() < 0.2; + private smallRankedMapAndCompact(): { map: GameMapType; isCompact: boolean } { + const maps = [ + GameMapType.Australia, // 40% + GameMapType.Australia, + GameMapType.Iceland, // 20% + GameMapType.Asia, // 20% + GameMapType.EuropeClassic, // 20% + ]; + return { + map: maps[Math.floor(Math.random() * maps.length)], + isCompact: Math.random() < 0.2, + }; + } + + public get1v1Config(): GameConfig { + const { map, isCompact } = this.smallRankedMapAndCompact(); return { donateGold: false, donateTroops: false, - gameMap: maps[Math.floor(Math.random() * maps.length)], + gameMap: map, maxPlayers: 2, ... } satisfies GameConfig; } public get2v2Config(): GameConfig { - const maps = [ ... ]; - const isCompact = Math.random() < 0.2; + const { map, isCompact } = this.smallRankedMapAndCompact(); return { donateGold: true, donateTroops: true, - gameMap: maps[Math.floor(Math.random() * maps.length)], + gameMap: map, maxPlayers: 4, ... } satisfies GameConfig; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/MapPlaylist.ts` around lines 417 - 447, Extract the shared map selection, compact-mode roll, timer calculation, and spawn-immunity configuration from get1v1Config() and get2v2Config() into a small private helper in MapPlaylist. Update both methods to reuse that helper while preserving their mode-specific settings and existing configuration values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/server/MapPlaylist.ts`:
- Around line 417-447: Extract the shared map selection, compact-mode roll,
timer calculation, and spawn-immunity configuration from get1v1Config() and
get2v2Config() into a small private helper in MapPlaylist. Update both methods
to reuse that helper while preserving their mode-specific settings and existing
configuration values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e98e0bc8-186a-4533-aa3f-15a35753af54
📒 Files selected for processing (11)
docs/API.mdresources/lang/en.jsonsrc/client/Main.tssrc/client/Matchmaking.tssrc/client/components/RankedModal.tssrc/client/components/baseComponents/stats/PlayerStatsTree.tssrc/client/hud/layers/WinModal.tssrc/core/ApiSchemas.tssrc/core/game/Game.tssrc/server/MapPlaylist.tstests/matchmaking/e2e.mjs
🚧 Files skipped from review as they are similar to previous changes (3)
- src/core/game/Game.ts
- resources/lang/en.json
- tests/matchmaking/e2e.mjs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API deployed ahead of the client, so the omit-mode-for-1v1 backward compatibility is no longer needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An account allowlist doesn't stop one person multi-tabbing multiple accounts into a ranked game, so the IP heuristic stays. Dev is exempt (like Turnstile and the duplicate-account kick) because local testing (multi-tab, the 4-player e2e) is inherently same-IP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/server/GameServer.ts (1)
950-950: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winFail closed on missing matchmade team pins. In matchmade games, a missing
teamIndexis turned intonullon the client and treated as unpinned insrc/core/game/TeamAssignment.ts, so an incomplete split can drift from the matchmaker’s intended teams. Reject start or validate every active client before starting.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/GameServer.ts` at line 950, Update the matchmade game start flow around GameServer’s matchmakingTeamIndex(c) assignment to fail closed when any active client lacks a valid teamIndex. Validate all active clients before starting, or reject the start when a missing pin is detected, rather than allowing it to become null and be treated as unpinned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/server/GameServer.ts`:
- Line 950: Update the matchmade game start flow around GameServer’s
matchmakingTeamIndex(c) assignment to fail closed when any active client lacks a
valid teamIndex. Validate all active clients before starting, or reject the
start when a missing pin is detected, rather than allowing it to become null and
be treated as unpinned.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2d8bba7e-d9e5-4c71-ac12-e6e70abddb9b
📒 Files selected for processing (1)
src/server/GameServer.ts
Description:
Implements 2v2 ranked matchmaking end-to-end against the matchmaking API's 2v2 queues (API PR #419): core team pinning, the server's second checkin loop and game creation, and the client UI.
Core — deterministic team pinning
The matcher's assignment specifies exactly who plays with whom (
teams: [[a,d],[b,c]]), but team assignment previously only did clan/friend balancing and could scramble the ELO-balanced split.PlayerSchema/PlayerInfogain an optionalteamIndex— a server-stamped index into the game's team list, part ofGameStartInfoso it's identical on every client (same category asclanTag/friends, which already feed deterministic team assignment).assignTeamshonors pins unconditionally — before clan/friend grouping and pastmaxTeamSize(the matcher's balancing is authoritative) — and seeds the counts that balancing of any unpinned players sees. Pinned players still participate in the friend graph, so an unpinned friend is pulled toward a pinned player's team.Server
modeexplicitly (the API deployed ahead of the client, so no omit-for-back-compat needed).get2v2Config(): Team mode,playerTeams: 2,maxPlayers: 4, always-compact map, donations enabled (matching public team games),rankedType: "2v2"(the API's 2v2 ingestion has shipped;RankedTypegainsTwoVTwo).players→allowedPublicIdsso only the matched accounts can take the slots (also hardens 1v1), andteams→teamIndexstamps at game start. A malformed assignment logs a warning and falls back to creating the game without pins rather than stranding matched players.Client
open-matchmaking(dispatchers without a detail — homepage button, requeue URL — still mean 1v1).&mode=1v1/&mode=2v2, shows a 2v2 title (matchmaking_modal.title_2v2in en.json), and shows the real 2v2 ELO from the newleaderboard.twoVtwofield in/users/@me(the ranked modal's 2v2 card does too)./?requeue=2v2).player_stats_tree.ranked_2v2).Harnesses (
tests/matchmaking/)modequery param; asserts each queue sends its mode explicitly. 10/10.MM_MODE=2v2runs four real browser players through the real local worker's 2v2 queue and rides the flow into the started game. Asserts same gameId for all four, the 2v2 config, allowlist admission, and a deterministic 2 vs 2 in-game split read from each client's GameView (the software-WebGL gate is spoofed in test pages only). 8/8. 1v1 e2e still 6/6.Verification
npm test: 2,053 tests pass, including 7 new (6assignTeamspinning unit tests + a full-game pinned-split test throughsetup()).npx tsc --noEmit, ESLint clean.wrangler devAPI worker: 1v1 (6/6) and 2v2 (8/8) as above.🤖 Generated with Claude Code
Please complete the following:
(UI changes — the ranked modal's 1v1/2v2 cards — were verified with before/after screenshots in the live app during development.)