2025, Sep 25 19:00

Generate a Clean Per-Player Three-Player Game Schedule in Python Without Combinatorial Overhead

Learn a simple Python algorithm to schedule three-player games for a focal player, pairing each opponent exactly once and handling odd rosters - no brute force.

Scheduling three-player games sounds simple until you try to generate the schedule programmatically. The goal here is focused: for a chosen player, build a set of games where that player faces every other participant exactly once, with games composed of three players each. A straightforward combinations approach explodes the search space and doesn’t enforce the pairing rule you actually need. Below is a compact way to generate a clean schedule for one focal player, with an edge case handled when the total count of players doesn’t split evenly.

Problem setup

We want a result like this for the chosen player (say, "a") given an 11-player roster: game 1 = a, b, c; game 2 = a, d, e; game 3 = a, f, g; game 4 = a, h, i; game 5 = a, j, k. That is, "a" meets each other player exactly once, two opponents per game.

Naive approach that overshoots

A first attempt is to generate all 3-combinations and then filter those containing the focal player. That does produce valid triplets, but it also returns every possible trio with the focal player, which is far more than needed.

from itertools import combinations

roster = ['a','b','c','d','e','f','g','h','i','j','k']
group_size = 3
triads = list(combinations(roster, group_size))

games_for_a = []
for pack in triads:
    if 'a' in pack:
        games_for_a.append(pack)

This yields 45 triplets for the focal player because it includes every distinct pair from the remaining 10 players combined with "a". It doesn’t enforce the constraint of using each opponent exactly once across the final schedule.

Why this happens

The combinations call enumerates all possible groups of three, not a curated schedule. Filtering by membership of "a" keeps all triads where "a" appears, which is choose(10, 2) = 45: far beyond the five games we actually want. In other words, you’re generating the universe and then trying to prune it, rather than constructing the intended schedule directly.

Direct construction with simple indexing

A more direct, deterministic construction is to walk the roster two at a time (skipping the focal player at index 0) and pair each adjacent opponent pair with the focal player. That produces exactly the desired schedule.

roster = ['a','b','c','d','e','f','g','h','i','j','k']
schedule = {}

for idx in range(1, len(roster), 2):
    schedule[f"game {int((idx + 1) / 2)}"] = ['a', roster[idx], roster[idx + 1]]

print(schedule)

Output:

{'game 1': ['a', 'b', 'c'], 'game 2': ['a', 'd', 'e'], 'game 3': ['a', 'f', 'g'], 'game 4': ['a', 'h', 'i'], 'game 5': ['a', 'j', 'k']}

How it works is straightforward. The iteration starts at index 1 and advances by 2, so you take opponent pairs [1,2], [3,4], [5,6], and so on. Each pair is joined with the focal player "a" to form a game. The game number is derived from the loop index via integer arithmetic.

Edge case: leftover opponent

If the roster size means you end up with one opponent left after chunking into pairs, you can still produce a final entry containing just the focal player and that last opponent. A try/except keeps the loop compact.

roster = ['a','b','c','d','e','f','g','h','i','j','k','l']
schedule = {}

for idx in range(1, len(roster), 2):
    try:
        schedule[f"game {int((idx + 1) / 2)}"] = ['a', roster[idx], roster[idx + 1]]
    except IndexError:
        schedule[f"game {int((idx + 1) / 2)}"] = ['a', roster[idx]]

print(schedule)

Output:

{'game 1': ['a', 'b', 'c'], 'game 2': ['a', 'd', 'e'], 'game 3': ['a', 'f', 'g'], 'game 4': ['a', 'h', 'i'], 'game 5': ['a', 'j', 'k'], 'game 6': ['a', 'l']}

This version preserves the same iteration strategy and handles the final singleton gracefully when the remaining count is odd after removing the focal player.

Feasibility note you should keep in mind

There’s a broader constraint worth noting when thinking about “everyone meets everyone exactly once” across all games of size three. With 11 players, there are 55 unique pairs; each game covers 3 pairs; and 55 is not divisible by 3. That arithmetic mismatch shows why a global schedule covering every pair exactly once cannot exist under those conditions. If you’re exploring full-season designs of 3-player groups with pairwise uniqueness, research on Steiner Triple System is relevant for understanding when such schedules are possible.

Why this matters

When generating schedules programmatically, it’s easy to fall into combinatorial generation and post-filtering. That path produces valid groups but not the specific structure you need, and it can hide feasibility constraints. A direct construction with indexing gives predictable results for the per-player case and keeps the implementation small and clear.

Wrap-up

Start from the intended constraints and construct the schedule directly rather than enumerating all combinations. For the per-player requirement, pairing the focal player with consecutive opponent pairs delivers exactly one meeting with each opponent, and a minimal try/except neatly handles a leftover opponent. If you extend the idea to full-league coverage where every pair meets exactly once, check the basic pair-count arithmetic first to avoid chasing impossible configurations.

The article is based on a question from StackOverflow by postcardfiction and an answer by Aadvik.