Round Robin is the format where every player meets every other player exactly once. League seasons, group stages, and chess tournaments all use it. The math is small, but the scheduling problem is more interesting than it looks.
The match count
For N players, the total number of matches is:
matches = N * (N - 1) / 2
That is the binomial coefficient C(N, 2) — the number of ways to pick 2 players from N. Each pair plays once.
| N | Matches | Rounds (N even) |
|---|---|---|
| 4 | 6 | 3 |
| 6 | 15 | 5 |
| 8 | 28 | 7 |
| 10 | 45 | 9 |
| 16 | 120 | 15 |
| 32 | 496 | 31 |
For even N, the tournament fits into N - 1 rounds with N / 2 matches per round. For odd N, one player sits out per round (a "bye"), and the tournament needs N rounds.
Naive scheduling fails
The obvious idea — just enumerate every pair — gives the right matches but a terrible schedule:
Round 1: (1-2), (1-3), (1-4), (1-5)
Round 2: (1-6), (1-7), (2-3), (2-4)
...
Player 1 plays four matches in a row. The same court is occupied. Players are exhausted. We need a schedule where each player plays once per round.
The circle method
This is the classic algorithm. Pin player 1 in place. Rotate the rest clockwise by one position each round. The pairings come from reading across the table.
For N = 6:
Round 1: 1-6 2-5 3-4
Round 2: 1-5 6-4 2-3
Round 3: 1-4 5-3 6-2
Round 4: 1-3 4-2 5-6
Round 5: 1-2 3-6 4-5
Each player meets every other exactly once across 5 rounds. Each round has 3 simultaneous matches.
Implementation
type Match = { round: number; home: number; away: number };
function roundRobin(players: number[]): Match[] {
const list = [...players];
// Odd N: add a phantom player, whoever pairs with it gets a bye
if (list.length % 2 === 1) list.push(-1);
const n = list.length;
const rounds = n - 1;
const half = n / 2;
const matches: Match[] = [];
for (let r = 0; r < rounds; r++) {
for (let i = 0; i < half; i++) {
const home = list[i];
const away = list[n - 1 - i];
// Skip the bye pair
if (home === -1 || away === -1) continue;
matches.push({ round: r + 1, home, away });
}
// Rotate: keep position 0 fixed, shift everything else by one
const fixed = list[0];
const rest = list.slice(1);
rest.unshift(rest.pop()!);
list.splice(0, list.length, fixed, ...rest);
}
return matches;
}
That is the whole algorithm. Roughly 25 lines.
Edge cases worth handling
-
Odd N. Adding a phantom (
-1) is cleaner than a special-case branch. Whoever pairs with the phantom in round R gets a bye that round. - Home/Away balance. The naive circle method gives some players many home games clustered together. The standard fix is to swap home/away for half the rotations — a separate pass after generation.
- Court assignment. With multiple courts, deal each round's matches across courts in order. The circle method already guarantees no player is in two matches per round.
Why I wrote this
I built a tournament generator at honeycup.ru — free, no signup, supports tennis, table tennis, chess, billiards, darts, and checkers. The round robin code above is essentially what is shipping in production.
The reason I am writing this: most open-source round robin implementations I read either skip odd-N handling, or hardcode assert N % 2 == 0, or generate the pairs without proper round assignment. The circle method is 60 years old and the standard answer — there is no reason not to use it.
Further reading
- Wikipedia: Round-robin tournament — circle method diagram
- Berger tables — chess-specific variant with home/away balancing
- For double-elimination math (which is much harder), see my next article.
If you handle odd-N or home/away balance differently — drop a comment.
Top comments (0)