Scheduling a padel americana: a rotation algorithm that lets everyone partner everyone
An americana is a padel social format: a group of players turns up, and for each round the pairs change so that you play with as many different people as possible. The scheduling problem sounds trivial until you try it with 7 players and 2 courts. This post walks through the algorithm we use in a TypeScript domain package, what it guarantees, and where it deliberately does not.
The core idea: partners come from the circle method
Round-robin tournaments use the "circle method": pin one slot, rotate the others one seat per round, and pair each slot with the one opposite. For an even number m of slots, over m - 1 rounds every unordered pair appears exactly once.
An americana needs the same property, but applied to partners instead of opponents. If the pairs from each round are used as that round's partnerships, then across a full rotation every player partners every other player exactly once. That is a 1-factorization of the complete graph, and it gives the main guarantee of the format for free.
type Slot = string | null; // Pin slot 0, rotate the rest by (round - 1) places. function rotate(slots: Slot[], round: number): Slot[] { const [pinned, ...rest] = slots; if (pinned === undefined || rest.length === 0) return [...slots]; const period = rest.length; const shift = (((round - 1) % period) + period) % period; const cut = period - shift; return [pinned, ...rest.slice(cut), ...rest.slice(0, cut)]; } function rotationPairs(slots: Slot[], round: number): [Slot, Slot][] { const ordered = rotate(slots, round); const half = ordered.length / 2; const front = ordered.slice(0, half); const back = ordered.slice(half).reverse(); return front.map((slot, i) => [slot, back[i]] as [Slot, Slot]); } Calling rotationPairs for rounds 1..n-1 with 8 players gives 7 rounds of 4 partnerships each, 28 distinct pairs in total, which is every pair of 8 players.
Odd counts: pad with a bye, and pad in the pinned seat
With an odd number of players you add a null slot so the rotation has an even size. Where you put it matters. The pinned slot's opposite partner changes every round, so putting the null in the pinned position makes the sit-out walk through every real player in turn. Put it anywhere else and the pinned player sits out every single round.
const padToEven = (ids: string[]): Slot[] => ids.length % 2 === 0 ? [...ids] : [null, ...ids]; // A partnership that contains null is a bye for the other player. function partnerships(ids: string[], round: number) { const pairs: [string, string][] = []; const sittingOut: string[] = []; for (const [a, b] of rotationPairs(padToEven(ids), round)) { if (a !== null && b !== null) pairs.push([a, b]); else sittingOut.push((a ?? b) as string); } return { pairs, sittingOut }; } The rotation length is therefore n - 1 for even n and n for odd n (the padded field has n + 1 slots). After that the schedule wraps and round k + length repeats round k.
const rotationLength = (n: number) => n < 2 ? (n === 1 ? 1 : 0) : n % 2 === 0 ? n - 1 : n; Courts: fewer courts than pairs
Pairs are not matches. Two pairs make a match, so 8 players give 4 pairs and 2 matches. If the venue only has 1 court, one match is played and the other two pairs rest.
A pair rests as a unit, so we pick which ones by accumulated bye load: the pairs whose members have sat out the fewest rounds rest next. A seeded random value breaks ties so the same low-index pair is not chosen every round.
function splitByCourts( pairs: [string, string][], byeLoad: Map<string, number>, courts: number, rng: () => number ) { const pooled = pairs.map((pair) => ({ pair, tie: rng() })); const playingCount = Math.min(Math.floor(pairs.length / 2), courts) * 2; const restCount = pairs.length - playingCount; if (restCount <= 0) return { playing: pooled, resting: [] }; const ranked = pooled .map((p, index) => ({ index, load: (byeLoad.get(p.pair[0]) ?? 0) + (byeLoad.get(p.pair[1]) ?? 0), tie: p.tie, })) .sort((a, b) => a.load - b.load || a.tie - b.tie); const resting = new Set(ranked.slice(0, restCount).map((r) => r.index)); return { playing: pooled.filter((_, i) => !resting.has(i)), resting: pooled.filter((_, i) => resting.has(i)).map((p) => p.pair), }; } Note the honest consequence: because pairs rest together, byes are balanced greedily but not exactly. In our implementation a field of 7 over a full rotation ends up between 2 and 4 byes per player rather than 3 each. Making it exact needs lookahead over the whole schedule and costs partner diversity, which is the thing the format exists for.
Opponents: greedy, and honest about it
Partners are fixed by the rotation. Who plays whom is a second, softer problem. We match the round's partnerships onto courts greedily: take the first pair, then choose the remaining pair whose four players have met the fewest times so far.
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`); function meetingCost( home: [string, string], away: [string, string], meetings: Map<string, number> ) { let cost = 0; for (const a of home) for (const b of away) cost += meetings.get(pairKey(a, b)) ?? 0; return cost; } This is not optimal, because committing pair by pair can strand a repeat for the last court. Measured on our implementation, a full 8-player rotation covers 26 of the 28 possible opponent pairings, and a 12-player one covers 62 of 66. A schedule where everyone meets everyone equally often as partner and as opponent only exists for particular n, so we do not promise it.
Determinism
The round is a pure function of (playerIds, roundNumber, seed, previousRounds): no clock and no Math.random. That means you can regenerate round 4 on its own, print the same sheet twice, or replay a disputed night. Stats are then just counting:
function maxPartnerRepeats(rounds: { fixtures: { sideA: string[]; sideB: string[] }[] }[]) { const seen = new Map<string, number>(); for (const r of rounds) for (const f of r.fixtures) for (const side of [f.sideA, f.sideB]) if (side.length === 2) { const k = pairKey(side[0], side[1]); seen.set(k, (seen.get(k) ?? 0) + 1); } return Math.max(0, ...seen.values()); } Within one full rotation this returns 1.
When the field changes mid-event
The circle-method guarantee assumes a fixed set of players. A late arrival or an early leaver breaks it. We detect that by comparing the players named in earlier rounds with the current list. If any earlier round was drawn for a different field, we switch to a greedy repair: lowest bye count rests, and partnerships are built by minimising repeats against the partner history. It does not restore the exactly-once guarantee, but it avoids repeats a repeat-free pairing could avoid. One bug worth knowing about: our first version switched back to the circle method as soon as the field looked complete again, which re-paired people who had already partnered earlier in the night. Staying on the repair for the rest of the event fixed it.
What this does not do
- No level balancing: the rotation reads no ratings. If you want matched teams, you need a different pairing mode.
- No moving winners up courts. That is the mexicano format, which depends on results.
- Doubles only. A singles "americana" is just a round robin.
Try it without writing code
If you just need a sheet for tonight, we put this algorithm behind a free tool: the americana generator takes a player list and number of courts and prints the rounds.