DEV Community

Cover image for Building a Round Robin Tournament Generator: the math behind N*(N-1)/2
Виктор Ушаков
Виктор Ушаков

Posted on

Building a Round Robin Tournament Generator: the math behind N*(N-1)/2

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
Enter fullscreen mode Exit fullscreen mode

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)
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

If you handle odd-N or home/away balance differently — drop a comment.

Top comments (0)