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.
- Sort by (score, rating). Odd count: remove the lowest-ranked player without a bye (FIDE C2, C5).
- In the top group, pair top half against bottom half (S1 vs S2, ordered by tournament pairing number).
- Reject pairs that break an absolute rule: rematch, or three same colours in a row.
- On rejection try the next transposition of the bottom half, then an exchange between halves (C.04.3 §3.6).
- A player left over floats down and is paired first in the next group (FIDE penalises repeated floats).
- Give each player the colour they played less; if balanced, alternate.
- 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
In production
- Chess: FIDE-rated events use engines implementing C.04.3 (JaVaFo, bbpPairings).
- Online servers: Lichess Swiss events (the lila
swissmodule 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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| bbpPairings | C++ | BieremaBoyzProgramming/bbpPairings | make from source or releases page | — | — | — | — | Dutch (2025 rules) and Burstein; always weighted matching |
| JaVaFo | Java | JaVaFo page | download from the page | — | — | — | — | Dutch engine by Roberto Ricca; its interface is the de facto standard |
| networkx | Python | networkx/networkx | pip 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