Skip to content

feat(lobby): Swiss tournament organizer for lobby broker (#4612)#4615

Closed
claytonlin1110 wants to merge 4 commits into
phase-rs:mainfrom
claytonlin1110:feat/swiss-tournament-organizer
Closed

feat(lobby): Swiss tournament organizer for lobby broker (#4612)#4615
claytonlin1110 wants to merge 4 commits into
phase-rs:mainfrom
claytonlin1110:feat/swiss-tournament-organizer

Conversation

@claytonlin1110

Copy link
Copy Markdown
Contributor

Summary

  • Adds a lobby-only Swiss tournament organizer to lobby-broker: registration, Swiss pairing with rematch avoidance, Magic tiebreaker standings, and self-reported match results.
  • Wires new protocol v12 messages through server-core, phase-server, and the Cloudflare lobby-worker WASM shell (shared ConnState + DO snapshot).
  • Adds client tournament pages (/tournament, /tournament/:code), tournamentClient.ts, and a link from Multiplayer.

Closes #4612.

Test plan

  • cargo test -p lobby-broker (90 tests)
  • cargo test -p server-core --test lobby_wire_contract (5 tests)
  • cargo check -p phase-server
  • Manual: create tournament on LobbyOnly broker, join players, start round, report results, verify standings
  • Rebuild/deploy lobby-worker WASM before production (protocol v12 + broker snapshot shape)

…#4612)

Implements lobby-only Swiss events with pairing, standings, and P2P
self-reported results across lobby-broker, both shells, and the client.
Bumps wire protocol to v12.

Co-authored-by: Cursor <cursoragent@cursor.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a Swiss-pairing tournament organizer system, including a backend registry, protocol updates (bumping to version 12), and a React frontend for creating, joining, and managing tournaments. The review feedback highlights several critical issues that must be addressed: a backtracking bug in the pairing algorithm for odd-sized brackets, missing winner validation on unequal game wins, premature tournament expiration due to static creation timestamps, and multiple WebSocket connection leaks on component unmounts and RPC errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +698 to +700
if players.len() == 1 {
return None;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Backtracking bug for odd-sized brackets

When the pool of players has an odd length, backtrack_pair will eventually recurse down to a single remaining player. Because it returns None when players.len() == 1, the entire backtracking search fails and falls back to the greedy pairing strategy. Returning Some(acc.clone()) instead allows the remaining player to be floated correctly.

Suggested change
if players.len() == 1 {
return None;
}
if players.len() == 1 {
return Some(acc.clone());
}

Comment on lines +326 to +328
if result.player_a_wins == result.player_b_wins && result.winner_player_key.is_some() {
return Err("Draw results must not specify a winner".to_string());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Missing winner validation on unequal game wins

validate_match_result currently only ensures that draw results do not specify a winner. It does not verify that the reported winner_player_key actually matches the player with more game wins when the game wins are unequal. This allows clients to self-report invalid match results (e.g., reporting 2-0 but marking the other player as the winner).

    if result.player_a_wins == result.player_b_wins {
        if result.winner_player_key.is_some() {
            return Err("Draw results must not specify a winner".to_string());
        }
    } else {
        let expected_winner = if result.player_a_wins > result.player_b_wins {
            &pairing.player_a
        } else {
            player_b
        };
        if result.winner_player_key.as_ref() != Some(expected_winner) {
            return Err("Winner must match the player with more game wins".to_string());
        }
    }

Comment on lines +275 to +288
pub fn check_expired(&mut self, timeout_secs: u64, env: &impl BrokerEnv) -> Vec<String> {
let now = env.now_ms();
let cutoff = now.saturating_sub(timeout_secs.saturating_mul(1000));
let expired: Vec<String> = self
.tournaments
.iter()
.filter(|(_, t)| t.created_at < cutoff)
.map(|(code, _)| code.clone())
.collect();
for code in &expired {
self.tournaments.remove(code);
}
expired
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Premature tournament expiration

Tournaments are reaped purely based on created_at < cutoff. Since created_at is never updated after registration, any active tournament that takes longer than timeout_secs to complete will be abruptly deleted mid-game. To fix this, update created_at (or introduce a last_activity_at field) on any write/mutation operation (like join_tournament, start_round, report_result) and use that for expiration checks.

Comment on lines +34 to +35
const client = await connect();
if (!client || cancelled) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on unmount

If the connect() promise resolves after the component has unmounted (or after dependencies change), cancelled will be true. However, the newly opened client is never closed, leading to a leaked WebSocket connection. Ensure the client is closed if the effect has been cancelled.

        const client = await connect();
        if (!client) return;
        if (cancelled) {
          client.close();
          return;
        }

Comment on lines +55 to +75
const handleCreate = async () => {
setLoading(true);
setError(null);
try {
const client = await connect();
if (!client) return;
const created = await client.createTournament({
name: name.trim(),
displayName: displayName.trim(),
totalRounds: rounds,
});
client.close();
navigate(`/tournament/${created.tournamentCode}`, {
state: { playerKey: created.playerKey, organizer: true },
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on RPC error

If client.createTournament throws an error, the execution jumps straight to the catch block, bypassing client.close(). This leaks the WebSocket connection on every failed creation attempt. Declare client outside the try block and close it in a finally block.

  const handleCreate = async () => {
    setLoading(true);
    setError(null);
    let client;
    try {
      client = await connect();
      if (!client) return;
      const created = await client.createTournament({
        name: name.trim(),
        displayName: displayName.trim(),
        totalRounds: rounds,
      });
      navigate(`/tournament/${created.tournamentCode}`, {
        state: { playerKey: created.playerKey, organizer: true },
      });
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      client?.close();
      setLoading(false);
    }
  };

Comment on lines +72 to +90
const handleJoin = async () => {
if (!code) return;
setJoining(true);
setError(null);
try {
const client = await connect();
const view = await client.joinTournament(code, displayName.trim());
setTournament(view);
const self = view.standings.find(
(s) => s.displayName.toLowerCase() === displayName.trim().toLowerCase(),
);
if (self) setPlayerKey(self.playerKey);
client.close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setJoining(false);
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on RPC error

If client.joinTournament throws an error, the execution jumps straight to the catch block, bypassing client.close(). This leaks the WebSocket connection on every failed join attempt. Declare client outside the try block and close it in a finally block.

Suggested change
const handleJoin = async () => {
if (!code) return;
setJoining(true);
setError(null);
try {
const client = await connect();
const view = await client.joinTournament(code, displayName.trim());
setTournament(view);
const self = view.standings.find(
(s) => s.displayName.toLowerCase() === displayName.trim().toLowerCase(),
);
if (self) setPlayerKey(self.playerKey);
client.close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setJoining(false);
}
};
const handleJoin = async () => {
if (!code) return;
setJoining(true);
setError(null);
let client;
try {
client = await connect();
const view = await client.joinTournament(code, displayName.trim());
setTournament(view);
const self = view.standings.find(
(s) => s.displayName.toLowerCase() === displayName.trim().toLowerCase(),
);
if (self) setPlayerKey(self.playerKey);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
client?.close();
setJoining(false);
}
};

Comment on lines +92 to +101
const handleStartRound = async () => {
if (!code) return;
try {
const client = await connect();
await client.startRound(code);
client.close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on RPC error

If client.startRound throws an error, the execution jumps straight to the catch block, bypassing client.close(). This leaks the WebSocket connection on every failed start round attempt. Declare client outside the try block and close it in a finally block.

  const handleStartRound = async () => {
    if (!code) return;
    let client;
    try {
      client = await connect();
      await client.startRound(code);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      client?.close();
    }
  };

Comment on lines +103 to +117
const handleReport = async (
matchId: string,
winnerKey: string | null,
aWins: number,
bWins: number,
) => {
if (!code) return;
try {
const client = await connect();
await client.reportMatchResult(code, matchId, winnerKey, aWins, bWins);
client.close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on RPC error

If client.reportMatchResult throws an error, the execution jumps straight to the catch block, bypassing client.close(). This leaks the WebSocket connection on every failed report attempt. Declare client outside the try block and close it in a finally block.

  const handleReport = async (
    matchId: string,
    winnerKey: string | null,
    aWins: number,
    bWins: number,
  ) => {
    if (!code) return;
    let client;
    try {
      client = await connect();
      await client.reportMatchResult(code, matchId, winnerKey, aWins, bWins);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      client?.close();
    }
  };

Comment on lines +119 to +129
const handleDrop = async () => {
if (!code) return;
try {
const client = await connect();
await client.dropFromTournament(code);
client.close();
navigate("/tournament");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on RPC error

If client.dropFromTournament throws an error, the execution jumps straight to the catch block, bypassing client.close(). This leaks the WebSocket connection on every failed drop attempt. Declare client outside the try block and close it in a finally block.

  const handleDrop = async () => {
    if (!code) return;
    let client;
    try {
      client = await connect();
      await client.dropFromTournament(code);
      navigate("/tournament");
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      client?.close();
    }
  };

Comment on lines +131 to +141
const handleEnd = async () => {
if (!code) return;
try {
const client = await connect();
await client.endTournament(code);
client.close();
navigate("/tournament");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] WebSocket connection leak on RPC error

If client.endTournament throws an error, the execution jumps straight to the catch block, bypassing client.close(). This leaks the WebSocket connection on every failed end attempt. Declare client outside the try block and close it in a finally block.

Suggested change
const handleEnd = async () => {
if (!code) return;
try {
const client = await connect();
await client.endTournament(code);
client.close();
navigate("/tournament");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const handleEnd = async () => {
if (!code) return;
let client;
try {
client = await connect();
await client.endTournament(code);
navigate("/tournament");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
client?.close();
}
};

claytonlin1110 and others added 3 commits June 29, 2026 16:49
Fixes ESLint no-unused-vars failure in check-frontend CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use MenuShell description instead of subtitle and re-add the lazy
DraftSpectatorPage import dropped during tournament route wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
Lobby brokers accept protocol 11 during rollout; v10 is below the floor.

Co-authored-by: Cursor <cursoragent@cursor.com>
@matthewevans matthewevans self-assigned this Jun 29, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found several substantive blockers in the current tournament implementation:

[HIGH] Tournament authority is stored on the socket, but the UI closes that socket immediately. Evidence: client/src/pages/TournamentLandingPage.tsx:66; crates/lobby-broker/src/broker.rs:353. Why it matters: creating a tournament stores organizer authority in ConnState, then closing the socket triggers on_disconnect and unregisters the tournament; joined players also lose joined_tournaments, so later report/drop calls from fresh sockets are unauthorized. Suggested fix: keep one tournament WebSocket alive for the page lifetime, or switch to explicit organizer/player tokens and stop deleting tournaments on ordinary socket close.

[HIGH] Match result validation accepts a winner that contradicts the game-win score. Evidence: crates/lobby-broker/src/tournament.rs:326. Why it matters: a client can report 2-0 while naming the losing player as winner_player_key, corrupting match points and standings. Suggested fix: when wins differ, require winner_player_key to equal the paired player with the higher game-win count.

[HIGH] Active tournaments expire solely by creation time. Evidence: crates/lobby-broker/src/tournament.rs:275; crates/phase-server/src/main.rs:1004. Why it matters: the native server reaper runs with a 300s timeout, so an in-progress Swiss event lasting more than five minutes can be deleted mid-round even if players are actively joining/reporting. Suggested fix: track last_activity_at and update it on tournament mutations, or restrict stale reaping to abandoned registration state.

[MED] Worker reaping stops for tournament-only broker state. Evidence: lobby-worker/broker-wasm/src/lib.rs:175. Why it matters: is_empty() only checks lobby games, so after the first non-expiring alarm a Durable Object with only tournaments stops rescheduling cleanup and can retain abandoned tournaments until another mutation. Suggested fix: make the WASM emptiness predicate include both lobby games and tournaments.

[MED] Odd-sized Swiss brackets always miss the backtracking success path. Evidence: crates/lobby-broker/src/tournament.rs:698. Why it matters: every odd bracket recurses to one remaining player, returns None, and falls back to greedy pairing that can create avoidable rematches despite a valid float-down solution. Suggested fix: treat one remaining player as a successful partial pairing and let pair_bracket_with_backtracking identify that player as the float.

[MED] Tournament subscription cleanup unsubscribes but never closes the WebSocket. Evidence: client/src/services/tournamentClient.ts:296. Why it matters: navigating away removes the listener and sends UnsubscribeLobby, but the socket stays open and continues counting as a live broker connection. Suggested fix: make the page cleanup close the tournament client, including the cancelled-connect path.

[LOW] New tournament UI chrome bypasses the i18n boundary. Evidence: client/src/pages/TournamentLandingPage.tsx:87. Why it matters: frontend-authored labels/buttons/descriptions will remain English-only despite client/src/i18n/README.md requiring t() for chrome strings. Suggested fix: add tournament keys to the appropriate i18n namespace and route new authored UI text through useTranslation.

@matthewevans matthewevans added the feature Larger-scoped feature label Jun 29, 2026
@matthewevans matthewevans removed their assignment Jun 29, 2026
@matthewevans

Copy link
Copy Markdown
Member

Thanks for putting time into this. I’m going to close this PR in its current form.

This is a large cross-stack feature touching the lobby broker, shared protocol, native server, Cloudflare worker, and React client. A change at this scale needs design discussion and maintainer alignment before PR work starts.

Please either come by Discord to discuss the feature/design with us, or open a proposal in the repo’s Discussions area before doing further implementation work. The proposal should cover the intended user flow, broker/server/worker lifecycle model, protocol changes, UI states, rollout plan, and verification strategy.

This is not a rejection of the tournament idea. It may be worth doing, but it needs to start as a design discussion first, then be split into small reviewable PRs once the direction is agreed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Swiss-pairing tournament organizer to the lobby broker

2 participants