The PMF Algorithm - How PlayMore Generates Fair Tournaments
Published on Mar 9, 2026
PMF (Play More Football) is the Swiss youth football development format. Every team plays exactly 8 games across 8 rounds, on mixed field sizes (2v2/3v3 for G Juniors, 3v3/4v4 for F Juniors). When 11 teams show up for a G Juniors tournament, the algorithm must:
- Generate 8 rounds of matches for 11 teams (someone sits out each round)
- Distribute bye rounds fairly (max 1 per team until all have 1)
- Balance field type assignments (each team should play on different field sizes)
- Avoid same-club matchups when configured
- Maximize unique pairings (minimize repeat matches)
- Do all of this in one pass, client-side, in under a second
This post describes how the algorithm works, with real code from the codebase. All examples use TypeScript from the actual implementation, simplified for clarity.
Table of Contents
- What Makes PMF Different
- The Generation Pipeline
- Hard Constraints: Team Availability
- BYE-Aware Pairing
- Priority Scoring System
- Why Exponential Works
- Top-Tier Selection
- Field Type Assignment
- Same-Club Avoidance
- E Juniors: The Two-Phase System
- Concrete Walkthrough
- Performance
- Wrapping Up
What Makes PMF Different
For readers familiar with standard tournament formats, PMF sits in an unusual space:
Not elimination. Nobody goes home. Every team plays 8 games regardless of results. The focus is development, not filtering.
Not round-robin. With 12 teams, round-robin would need 11 rounds and 66 matches. PMF uses 8 rounds with around 24-36 matches. Not every team plays every other team.
Swiss-system adapted. Similar to chess Swiss pairing, but adapted for youth development. In chess Swiss, the goal is competitive seeding. In PMF, the goal is variety — field type rotation matters more than matching teams of similar strength.
Three categories with different rules. G Juniors (2v2/3v3), F Juniors (3v3/4v4), and E Juniors (two-phase system with club aggregation). Each has different field configurations, but the core pairing algorithm is the same.
The combination of these constraints — guaranteed games, field rotation, fair BYE distribution, same-club avoidance — makes PMF scheduling a constraint satisfaction problem. A human can approximate it in an afternoon with a spreadsheet. The algorithm solves it in under a second.
The Generation Pipeline
The algorithm runs in phases. Each round is generated independently, but tracking state accumulates across rounds:
The tracking maps carry forward between rounds: pairing usage counts, field type exposure per team, recent opponents, game counts, and BYE counts. Each round builds on the accumulated state of all previous rounds, which is why the algorithm produces progressively better-balanced pairings as it runs — later rounds have more data to work with.
Hard Constraints: Team Availability
Before any priority scoring happens, the isTeamAvailable() function acts as a gatekeeper. It enforces hard constraints that cannot be violated regardless of priority scores:
function isTeamAvailable(teamName: string, round: number): boolean {
// 1. Already playing this round?
const playingThisRound = roundMatches[round].some(
(match) => match.team_a === teamName || match.team_b === teamName
);
if (playingThisRound) return false;
// 2. Already played 8 games (maximum reached)?
const teamGames = teamGameCounts.get(teamName) || 0;
if (teamGames >= 8) return false;
// 3. Has 7 games but others have fewer?
if (teamGames >= 7) {
const minGamesPlayed = Math.min(...Array.from(teamGameCounts.values()));
if (minGamesPlayed < 7) return false;
}
return true;
}The third check is the important one. It prevents any team from getting their 8th game while others have not had their 7th. This guarantees even distribution across all teams. Without it, the priority system could inadvertently favor certain teams based on their pairing scores.
BYE-Aware Pairing
With odd team counts (5, 7, 9, 11 teams), one team sits out each round. Naive approaches assign BYEs unevenly — some teams get 2-3 BYEs while others get 0.
The algorithm reserves the BYE team proactively before generating pairings, not reactively afterward:
function selectBYETeam(round: number): string | null {
const availableTeams = Array.from(allTeamNames).filter((teamName) => {
// Don't give BYE to teams already playing this round
return !roundMatches[round].some(
(match) => match.team_a === teamName || match.team_b === teamName
);
});
// Enforce fair distribution: max BYE difference <= 1
const minBYEs = Math.min(...Array.from(teamBYECounts.values()));
const maxBYEs = Math.max(...Array.from(teamBYECounts.values()));
let candidateTeams = availableTeams;
if (maxBYEs - minBYEs >= 1) {
// Only teams with minimum BYE count are candidates
const teamsWithMinBYEs = availableTeams.filter(
(teamName) => (teamBYECounts.get(teamName) || 0) === minBYEs
);
if (teamsWithMinBYEs.length > 0) candidateTeams = teamsWithMinBYEs;
}
// Among candidates: fewest games first, then fewest BYEs
const sorted = candidateTeams.sort((a, b) => {
const aGames = teamGameCounts.get(a) || 0;
const bGames = teamGameCounts.get(b) || 0;
if (aGames !== bGames) return aGames - bGames;
return (teamBYECounts.get(a) || 0) - (teamBYECounts.get(b) || 0);
});
return sorted[0];
}The constraint maxBYEs - minBYEs >= 1 means no team can get a second BYE until every team has had at least one. With 11 teams and 8 rounds, most teams get 1 BYE and a few get 0 — the difference never exceeds 1.
The proactive approach matters. A reactive system would generate all pairings first, then realize one team is left over and assign it a BYE — with no fairness guarantee. By reserving the BYE team before pairing starts, the algorithm can always pick the fairest candidate.
Priority Scoring System
The core intelligence of the algorithm lives in calculateCompositePriority(). Four factors compose the score for each candidate pairing:
1. Usage Priority (0-1200 points)
An exponential penalty curve determines how strongly the algorithm prefers unused pairings:
let usagePriority = Math.max(0, 1000 - usageCount * usageCount * 200);
// Late-round boost for unused pairings
if (usageCount === 0 && currentRound >= 5) {
usagePriority += 200;
}| Times Played | Points | Effect |
|---|---|---|
| 0 | 1000 | Strongly preferred |
| 0 (round 5+) | 1200 | Maximum priority |
| 1 | 800 | Acceptable |
| 2 | 200 | Discouraged |
| 3+ | 0 | Eliminated |
2. Field Balance Priority (0-100 points)
How much does this pairing help both teams get balanced field types?
availableFieldTypes.forEach((fieldType) => {
const teamANeed = calculateFieldTypeNeed(teamABalance, fieldType);
const teamBNeed = calculateFieldTypeNeed(teamBBalance, fieldType);
maxBalanceImprovement = Math.max(maxBalanceImprovement, teamANeed + teamBNeed);
});If Team A has played 5x on 3v3 but only 2x on 4v4, pairing them for a 4v4 field scores higher. The target is 4 games per field type over 8 rounds.
3. Recent Opponent Penalty (0-50 points deducted)
Teams that played in the last 2 rounds get a penalty. This encourages variety across consecutive rounds:
function calculateRecentOpponentPenalty(pairing, currentRound, recentOpponents): number {
const recentA = recentOpponents.get(teamA.name) || new Set();
let penalty = 0;
if (recentA.has(teamB.name)) penalty += 25;
if (recentB.has(teamA.name)) penalty += 25;
return Math.min(50, penalty);
}4. Composite Score
const compositeScore = usagePriority + maxBalanceImprovement - recentOpponentPenalty;The four components add up to a single number that the selection phase uses. Higher is better. A perfect pairing — never played, great field balance, no recent contact — might score 1280. A poor pairing — played twice, no field balance benefit, recent opponent — might score 150.
Why Exponential Works
The choice of usageCount^2 * 200 over a linear curve is deliberate.
Linear (e.g., -200 per use): 1000, 800, 600, 400 — a 3x repeat still gets 400 points. Not enough deterrence. The algorithm would allow repeats too readily.
Exponential (usageCount^2 * 200): 1000, 800, 200, 0 — a 2x repeat gets 75% less priority than a 1x repeat. A 3x repeat is eliminated entirely.
This creates a strong pull toward unique pairings while allowing strategic repeats when other constraints require it. With 8 teams, there are C(8,2) = 28 possible pairings and 32 matches needed (8 rounds * 4 matches). The exponential curve ensures all 28 pairings get used at least once, with only 4 repeats.
The late-round boost (+200 for unused pairings in rounds 5-8) adds urgency. By round 5, any pairing that has never been used gets the highest possible score (1200), making it very likely to be selected before the tournament runs out of rounds.
Top-Tier Selection
After scoring all candidates, the algorithm does not simply pick the highest. Deterministic selection would produce identical tournaments from the same input.
// Find the maximum score
const maxScore = Math.max(...validPairings.map((p) => p.compositeScore));
// Filter to candidates within 10 points of max
const topTier = validPairings.filter((p) => p.compositeScore >= maxScore - 10);
// Random pick within the top tier
const selected = topTier[Math.floor(Math.random() * topTier.length)];The 10-point tolerance introduces controlled variety. A pairing scored at 1195 is “close enough” to one at 1200 that either would be fine. Running the algorithm twice with the same teams produces different but equally good results.
Field Type Assignment
After selecting a pairing, the algorithm assigns the optimal field type. Each F Juniors tournament has a fixed field configuration (e.g., fields 1-2 are 3v3, field 3 is 4v4). The assignment considers both teams’ balance:
function selectOptimalField(teamA, teamB, fieldConfig, usedFields): { number; type } {
const available = Object.entries(fieldConfig).filter(([num]) => !usedFields.has(parseInt(num)));
const teamANeeds3v3 = (teamABalance.get('3v3') || 0) <= (teamABalance.get('4v4') || 0);
const teamBNeeds3v3 = (teamBBalance.get('3v3') || 0) <= (teamBBalance.get('4v4') || 0);
if (teamANeeds3v3 && teamBNeeds3v3) {
const field = available.find((f) => f.type === '3v3');
if (field) return field;
}
// ... similar for 4v4 preference
return available[0]; // fallback to first available
}The target is ~4 games of each type per team over 8 rounds. If both teams need more 3v3 exposure, they get a 3v3 field. If needs conflict, the algorithm uses the first available field and the balance corrects over subsequent rounds.
Field assignment runs after pairing selection, so it never influences which teams play each other. The pairing algorithm optimizes for matchup variety; field assignment optimizes for exposure balance. Separating these concerns keeps the logic clean and each step easier to reason about.
Same-Club Avoidance
When teams from the same club participate, organizers usually want to avoid intra-club matchups. The algorithm handles this as a hard filter during pairing generation:
function generateValidPairings(teams, avoidSameClub): Array<[Team, Team]> {
const validPairings = [];
for (let i = 0; i < teams.length; i++) {
for (let j = i + 1; j < teams.length; j++) {
if (avoidSameClub && teams[i].clubId === teams[j].clubId) continue;
validPairings.push([teams[i], teams[j]]);
}
}
return validPairings;
}When avoidSameClub is true, same-club pairings are excluded entirely from the candidate pool. They cannot be selected regardless of priority scores. This is a hard constraint, not a soft penalty.
Why a hard constraint instead of a penalty score? Because organizers have strong opinions about this. When a club brings FC Toss 1 and FC Toss 2 to a G Juniors tournament, they never want those teams playing each other — the kids are from the same training group. Making it a soft penalty would mean it could still happen under pressure from other constraints. A hard filter guarantees it does not.
The start wizard exposes this as a simple toggle. For F and G Juniors, clubs often bring multiple teams and want to avoid having them play each other. For E Juniors, the question is moot — Phase 2 aggregates teams into clubs anyway.
When the toggle is off, the algorithm treats same-club pairings like any other candidate. This is useful for friendly scrimmage tournaments where a club runs an internal event with only their own teams.
E Juniors: The Two-Phase System
E Juniors (ages 11-12) use a unique two-phase system handled by pmf-phase.ts:
Phase 1 (3v3): Individual teams compete. “FC Toss 1” and “FC Toss 2” are separate entries with their own standings. The algorithm pairs clubs against each other — both teams from Club A play both teams from Club B in the same round.
// Phase 1: Club-level pairing for E Juniors
if (isPMFEPhase1) {
const teamsByClub = new Map<string, Team[]>();
teams.forEach((team) => {
if (!teamsByClub.has(team.clubId)) teamsByClub.set(team.clubId, []);
teamsByClub.get(team.clubId)!.push(team);
});
// Pair clubs, then pair individual teams within the club matchup
// A1 vs B1, A2 vs B2 in the same round
}Phase 2 (6v6): Teams aggregate into clubs. “FC Toss 1” + “FC Toss 2” become “FC Toss”. The algorithm creates club-level team entries for Phase 2:
if (isPMFEPhase2) {
const clubTeams = new Map<string, Team>();
teams.forEach((team) => {
if (!clubTeams.has(team.clubId)) {
clubTeams.set(team.clubId, {
...team,
name: team.clubName || team.name.replace(' 1', '').replace(' 2', ''),
id: `${team.clubId}-club`
});
}
});
phaseTeams = Array.from(clubTeams.values());
}Standings carry over. The store’s two-tier name lookup (covered in the real-time stores post) handles the transition: Phase 1 matches reference “FC Toss 1”, Phase 2 matches reference “FC Toss”, and the standings view resolves both to the correct club.
The two-phase system is the most complex part of PlayMore’s tournament support. It requires the algorithm to generate two separate sets of rounds with different team rosters, field configurations, and pairing constraints — but present them as a single coherent tournament with continuous standings.
Concrete Walkthrough
To make all of this concrete, here is what happens during a single pairing decision.
Round 5 of an 8-team F Juniors tournament. 3 fields (2x 3v3, 1x 4v4). Same-club avoidance enabled. The first two pairings for this round have already been selected and assigned to the 3v3 fields.
6 teams are available (2 already matched this round). The algorithm needs to pick 1 more pairing for the last field (4v4).
Candidate scoring:
| Pairing | Used | Usage Pts | Balance Pts | Recent Penalty | Total |
|---|---|---|---|---|---|
| A vs C | 0 | 1200 | 80 | 0 | 1280 |
| A vs D | 1 | 800 | 40 | -25 | 815 |
| B vs C | 2 | 200 | 90 | 0 | 290 |
| E vs F | 0 | 1200 | 20 | 0 | 1220 |
Top tier (within 10 of max 1280): A vs C (1280) and E vs F (1220) are both above the threshold? No — 1220 is 60 points below 1280, so only A vs C qualifies.
Selected: A vs C with score 1280.
Why it won: Never played before (1000 + 200 late-round boost = 1200 usage points), good field balance improvement (80 points because both teams need more 4v4 exposure), no recent opponent penalty.
Why E vs F lost despite never playing: Also a fresh pairing with the late-round boost (1200 usage points), but low field balance benefit (20 points). Both teams E and F already have balanced field exposure, so a 4v4 field does not help them much. The 60-point gap puts E vs F outside the 10-point tolerance for top-tier selection.
Why B vs C scored so poorly: The exponential curve in action. 2 previous meetings drops the usage score from 1000 to 200 — a 80% drop. Despite excellent field balance (90 points), the pairing is the worst candidate at 290. A linear penalty would have given B vs C 600 usage points, making it a competitive candidate at 690. The exponential curve correctly identifies this as a pairing to avoid.
Performance
The entire algorithm runs client-side in the browser. For a typical 12-team G Juniors tournament (8 rounds, 4 matches per round), generation completes in under 100ms. Even the most complex case — an E Juniors two-phase tournament with 16 teams — finishes in well under a second.
The algorithm uses a multi-run approach: it generates several candidate tournaments, scores each one for quality (unique pairings, field balance, BYE fairness), and returns the best result. This adds a small time cost but produces measurably better tournaments. The quality scoring function checks metrics like repeat match count, field type standard deviation per team, and BYE count distribution.
Because generation is fast and deterministic given the same random seed, organizers can tap “regenerate” as many times as they want until they see a schedule they like. Each run produces a different but equally valid tournament.
The start wizard shows quality metrics after generation: number of unique pairings used, field balance distribution, BYE fairness score, and whether same-club avoidance was respected. This gives organizers confidence that the algorithm produced a fair result without needing to understand the scoring system.
Wrapping Up
The PMF algorithm is a constraint-based priority scoring system with controlled randomization. Hard constraints (team availability, BYE fairness, same-club avoidance) act as gatekeepers. Soft constraints (usage priority, field balance, recent opponents) combine into a composite score. Top-tier selection adds variety without sacrificing quality.
The result: balanced tournaments generated in milliseconds that would take a human hours to produce by hand. Every team plays 8 games, BYEs are distributed fairly, field types rotate evenly, and the schedule is different every time you run it.
This is the fourth post in the PlayMore series. For the architecture overview, see Building PlayMore. For the real-time data system, see PlayMore Real-Time Stores. For a non-technical overview, see PlayMore - Tournament Management That Actually Works.