Home › Multiplayer & game networking › Rating & matchmaking
algorithm medium

Swiss-system pairing

Problem Many competitors must be paired over a few rounds with nobody dropping out, so that equals meet, no pair repeats, sides stay balanced, odd counts are handled and a clear leader emerges.

Each round pairs competitors with equal scores, never twice and with balanced sides; a bye covers odd counts, and about log2(N) rounds expose a winner.

naive backtracking exponential in the worst case; weighted matching O(EV log V) per round (Galil-Micali-Gabow, as used by bbpPairings)
tournamentpairingswissmatchingbuchholzbyesbacktracking
Overview

A Swiss event has a fixed, small number of rounds and no eliminations. Each round pairs players with equal scores, so the field sorts itself in about log2(N) rounds where a round robin needs N−1. The side conditions (no rematch, balanced sides, one bye at most) make pairing a constraint problem, solved by backtracking or weighted matching.

Key facts

Use when

  • Chess federations and Lichess Swiss events: many entrants, few dates.
  • Card-game events (Magic: The Gathering) and esports stages (Counter-Strike Majors, three wins advance).
  • Judging rounds with many entrants, few sessions (application, not verified).

Avoid when

  • Rolling 1v1 play: use a matchmaking queue.
  • Every pair must meet (round robin) or knockout drama is wanted.

Deep dive

How it works

Parts:

  • Score groups — equal points, ordered by rating; the pairing unit.
  • History — per player: opponents, colours, bye, floats.
  • Pairing engine — Dutch procedure or weighted matching.
  • Bye allocator — free point for the odd player out, once each.
  • Tie-breaks — Buchholz or Sonneborn-Berger after the last round.
  1. Sort by (score, rating). Odd count: remove the lowest-ranked player without a bye (FIDE C2, C5).
  2. In the top group, pair top half against bottom half (S1 vs S2, ordered by tournament pairing number).
  3. Reject pairs that break an absolute rule: rematch, or three same colours in a row.
  4. On rejection try the next transposition of the bottom half, then an exchange between halves (C.04.3 §3.6).
  5. A player left over floats down and is paired first in the next group (FIDE penalises repeated floats).
  6. Give each player the colour they played less; if balanced, alternate.
  7. After the last round rank by score, then tie-breaks.
3.0 : A B C D  -> A-C  B-D
2.0 : E F G    -> E-G  F floats down
1.0 : H I J    -> F-H  I-J           (F arrives as a floater)

Why backtracking. The exact rules are a perfect matching with forbidden edges. Greedy pairing can strand the last two players (met before, or both need White); the repair undoes choices far above. FIDE's Dutch text is a search order over such alterations.

Weighted matching. One vertex per player, an edge per allowed pair, a weight that encodes the criteria in priority order. A maximum-weight perfect matching (Edmonds' blossom algorithm; the graph is not bipartite) gives the best pairing without search. The bbpPairings README says it always uses weighted matching for the Dutch rules. See minimum-weight perfect matching and bipartite matching.

Tie-breaks. Buchholz = sum of opponents' scores; Sonneborn-Berger = scores of opponents beaten plus half of those drawn. The Counter-Strike Major rulebook uses opponents' wins minus losses as its Difficulty Score (Buchholz).

Rounds. With equals paired and no draws, perfect records halve each round, so ⌈log2 N⌉ rounds leave one. Magic's table recommends 5 rounds for 17-32 players, 6 for 33-64, 7 for 65-128, 8 for 129-226 (MTR Appendix E): the same curve.

Code

pair is the backtracking search with the Dutch candidate order; round allocates the bye. Simulated results use Elo expected scores.

// Swiss pairing: score groups, no rematch, colour limits, one bye, floaters.
#[derive(Clone, Default)]
struct P { rating: f64, score: u32, opps: Vec<usize>, cols: Vec<bool>, bye: bool } // score in half-points, cols: true = White

fn last2(p: &P) -> Option<bool> {
    let n = p.cols.len();
    (n >= 2 && p.cols[n - 1] == p.cols[n - 2]).then(|| p.cols[n - 1])
}
fn diff(p: &P) -> i32 { p.cols.iter().map(|&w| if w { 1 } else { -1 }).sum() } // whites - blacks

// > 0: wants White. Balance counts double, alternating from the last game once.
fn want_white(p: &P) -> i32 { -2 * diff(p) + match p.cols.last() { Some(true) => -1, Some(false) => 1, None => 0 } }

/// Some(true) = `a` gets White. None = no legal colours (three in a row) for this pair.
fn colours(a: &P, b: &P, tie_white: bool) -> Option<bool> {
    let a_white = last2(a) != Some(true) && last2(b) != Some(false);
    let a_black = last2(a) != Some(false) && last2(b) != Some(true);
    match (a_white, a_black) {
        (true, true) => { let (x, y) = (want_white(a), want_white(b)); Some(x > y || (x == y && tie_white)) } // who needs White more gets it
        (w, bl) => (w || bl).then_some(w),
    }
}

/// `rest` is sorted by (score, rating) descending. Pair its top player: try the second half of
/// its group first, then the rest, then lower groups (a floater); backtrack on dead ends.
fn pair(ps: &[P], rest: &mut Vec<usize>, out: &mut Vec<(usize, usize, bool)>) -> bool {
    if rest.is_empty() { return true; }
    let a = rest.remove(0);
    let g = rest.iter().take_while(|&&x| ps[x].score == ps[a].score).count();
    let start = ((g + 1) / 2).saturating_sub(1);
    let mut order: Vec<usize> = (start..g).chain((0..start).rev()).chain(g..rest.len()).collect();
    // Own group first; inside it, opposite colour wishes first (stable sort keeps Dutch order).
    order.sort_by_key(|&k| (ps[a].score - ps[rest[k]].score, want_white(&ps[a]) * want_white(&ps[rest[k]]) > 0));
    for k in order {
        let b = rest[k];
        if ps[a].opps.contains(&b) { continue; }
        let Some(a_white) = colours(&ps[a], &ps[b], a % 2 == 0) else { continue };
        rest.remove(k);
        out.push((a, b, a_white));
        if pair(ps, rest, out) { return true; }
        out.pop();
        rest.insert(k, b);
    }
    rest.insert(0, a);
    false
}

/// One round: the bye goes to the lowest-ranked player without one that leaves a pairing.
fn round(ps: &[P]) -> Option<(Vec<(usize, usize, bool)>, Option<usize>)> {
    let mut order: Vec<usize> = (0..ps.len()).collect();
    order.sort_by(|&x, &y| (ps[y].score, ps[y].rating as i64).cmp(&(ps[x].score, ps[x].rating as i64)));
    let mut tries: Vec<Option<usize>> = vec![None];
    if order.len() % 2 == 1 { tries = order.iter().rev().filter(|&&i| !ps[i].bye).map(|&i| Some(i)).collect(); }
    for bye in tries {
        let mut rest: Vec<usize> = order.iter().copied().filter(|&i| Some(i) != bye).collect();
        let mut out = vec![];
        if pair(ps, &mut rest, &mut out) { return Some((out, bye)); }
    }
    None
}

fn main() {
    let (n, rounds) = (15, 7);
    let mut ps: Vec<P> = (0..n).map(|i| P { rating: 2200.0 - 60.0 * i as f64, ..P::default() }).collect();
    let mut seed = 0x9E3779B97F4A7C15u64;
    let mut rnd = move || { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; (seed >> 11) as f64 / (1u64 << 53) as f64 };
    for r in 0..rounds {
        let (pairs, bye) = round(&ps).expect("no legal pairing");
        assert_eq!(pairs.len() * 2 + bye.is_some() as usize, n); // everyone paired except the bye
        for &(a, b, a_white) in &pairs {
            assert!(!ps[a].opps.contains(&b)); // no rematch
            let pa = 1.0 / (1.0 + 10f64.powf((ps[b].rating - ps[a].rating) / 400.0));
            let (sa, sb) = if rnd() < 0.15 { (1, 1) } else if rnd() < pa { (2, 0) } else { (0, 2) };
            ps[a].score += sa; ps[b].score += sb;
            ps[a].opps.push(b); ps[b].opps.push(a);
            ps[a].cols.push(a_white); ps[b].cols.push(!a_white);
        }
        if let Some(i) = bye { assert!(!ps[i].bye); ps[i].bye = true; ps[i].score += 2; }
        println!("round {}: {} games, bye {:?}", r + 1, pairs.len(), bye);
    }
    assert!(ps.iter().all(|p| p.cols.windows(3).all(|w| !(w[0] == w[1] && w[1] == w[2]))));
    assert_eq!(ps.iter().filter(|p| p.bye).count(), rounds); // one bye per round, none twice
    let buch = |p: &P| p.opps.iter().map(|&o| ps[o].score).sum::<u32>();
    let mut rank: Vec<usize> = (0..n).collect();
    rank.sort_by_key(|&i| std::cmp::Reverse((ps[i].score, buch(&ps[i]))));
    let (f, s) = (&ps[rank[0]], &ps[rank[1]]);
    assert!((f.score, buch(f)) > (s.score, buch(s)), "tie not broken by Buchholz");
    for &i in rank.iter().take(3) { println!("#{i}: {} pts (half-points), Buchholz {}", ps[i].score, buch(&ps[i])); }
}

Compiled and run (stable Rust, 15 players, 7 rounds): all asserts hold.

Complexity and constants

  • Backtracking is exponential in the worst case; in the runs below it stayed cheap once colour preference ordered the candidates.
  • Matching: O(EV log V) (bbpPairings README), about N³ per round.
  • Memory O(N·R). Cost drivers: rematch lookups (use a hash set) and backtracks.

Efficiency

Cost: measured on Apple M4 Pro, release build, including the simulated results: 15 players × 7 rounds about 0.07 ms; 201 × 8 about 5 ms; 1,001 × 10 about 0.1 s; 2,001 × 11 about 0.4 s (roughly quadratic here). Compute-bound, once per round.

Levers:

  • Order candidates by colour preference — an earlier draft did not finish round 3 for 64 players in 15 s (measured) — always; soft criteria belong in the order.
  • Weighted matching — polynomial worst case, no search (bbpPairings README) — when backtracking stalls.

Naive → standard → state of the art:

  • Random pairing in a score group · rematches
  • Backtracking Dutch search · simple, exponential worst case
  • Weighted matching, priority-encoded weights · polynomial, as in bbpPairings

Implementation notes

  • Keep scores as integers (half-points).
  • Alternate colours in round 1, or the winners all hold White and round 2 is full of same-preference pairs; this hung my first draft.
  • Pairing is pure (history in, pairs out); the authoritative server owns results and history.

Pitfalls

  • A second bye for one player.
  • Colour as a hard rule everywhere: FIDE exempts top scorers from C3 in the final round.
  • The same player floating in consecutive rounds.

Variants and alternatives

Dutch (FIDE C.04.3)the default for open events.
Dubov, Burstein, Lim (FIDE C.04.4)other approved rule sets.
Accelerated systems (FIDE C.04.7)an approved extension for the early rounds.
Round robinN−1 rounds; small fields.
Knockout bracketno second chance.

In production

  • Chess: FIDE-rated events use engines implementing C.04.3 (JaVaFo, bbpPairings).
  • Online servers: Lichess Swiss events (the lila swiss module calls BBPairing).
  • Card games: Magic tournaments are Swiss unless announced otherwise (MTR §10.4).
  • Esports: Counter-Strike Major stages are 16-team Swiss, three wins advance, three losses eliminate.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
bbpPairingsC++BieremaBoyzProgramming/bbpPairingsmake from source or releases page————Dutch (2025 rules) and Burstein; always weighted matching
JaVaFoJavaJaVaFo pagedownload from the page————Dutch engine by Roberto Ricca; its interface is the de facto standard
networkxPythonnetworkx/networkxpip install networkx————max_weight_matching to build a matching-based pairer

Sources

  • FIDE Handbook, C.04.1 Basic rules for Swiss Systems (from 1 Feb 2026). handbook
  • FIDE Handbook, C.04.3 FIDE (Dutch) System (from 1 Feb 2026). handbook
  • Bierema Boyz Programming, bbpPairings README. code
  • Edmonds, Paths, Trees, and Flowers, Canadian J. Mathematics 17, 1965. paper
  • Galil, Micali, Gabow, An O(EV log V) Algorithm for Finding a Maximal Weighted Matching in General Graphs, SIAM J. Computing 15(1), 1986. paper
  • Wizards of the Coast, Magic Tournament Rules (27 Feb 2026). pdf
  • Valve, Counter-Strike Major Supplemental Rulebook. rulebook
  • Lichess, lila Swiss module. source

Builds on

Used by

Systems (1):

Alternatives and related