20.23 Building a Matchmaking System
Design a matchmaking system that pairs users, players, tasks, or entities according to compatibility rules.
20.23 Building a Matchmaking System
Problem
Design a matchmaking system that pairs users, players, tasks, or entities according to compatibility rules.
Matchmaking appears in multiplayer games, marketplaces, job platforms, tutoring systems, dating apps, mentoring programs, ride-sharing systems, reviewer assignment tools, and distributed work schedulers. The input is a set of participants and preferences. The output is a set of matches.
Example:
players:
A rating=1200 region=asia
B rating=1230 region=asia
C rating=1700 region=europe
D rating=1710 region=europe
A reasonable match set is:
A vs B
C vs D
The core algorithmic problem is matching under constraints. The engineering problem is balancing fairness, speed, quality, and business rules.
Solution
Start with a greedy matcher over a compatibility score. For each unmatched participant, find the best currently available partner that satisfies hard constraints.
A participant model:
from dataclasses import dataclass
import math
@dataclass(frozen=True)
class Player:
id: str
rating: int
region: str
wait_seconds: int = 0
Hard constraints:
def compatible(left, right, max_rating_gap=200):
if left.id == right.id:
return False
if left.region != right.region:
return False
if abs(left.rating - right.rating) > max_rating_gap:
return False
return True
Scoring:
def match_score(left, right):
rating_penalty = abs(left.rating - right.rating)
wait_bonus = left.wait_seconds + right.wait_seconds
return -rating_penalty + 0.1 * wait_bonus
Higher score means better match.
Greedy Pairing
A simple greedy matcher scans possible pairs and repeatedly takes the best available pair.
def greedy_match(players):
candidates = []
for i, left in enumerate(players):
for right in players[i + 1:]:
if compatible(left, right):
candidates.append((
match_score(left, right),
left.id,
right.id,
))
candidates.sort(reverse=True)
matched = set()
matches = []
for score, left_id, right_id in candidates:
if left_id in matched or right_id in matched:
continue
matched.add(left_id)
matched.add(right_id)
matches.append({
"left": left_id,
"right": right_id,
"score": score,
})
return matches
Example:
players = [
Player("A", 1200, "asia"),
Player("B", 1230, "asia"),
Player("C", 1700, "europe"),
Player("D", 1710, "europe"),
]
print(greedy_match(players))
Possible output:
[
{'left': 'C', 'right': 'D', 'score': -10.0},
{'left': 'A', 'right': 'B', 'score': -30.0}
]
This works well as a first version. It is easy to explain and test.
Hard Constraints vs Soft Preferences
Separate hard constraints from soft preferences.
Hard constraints reject a match entirely:
different game mode
blocked users
incompatible language
invalid region
rating gap too large
Soft preferences affect ranking:
closer rating is better
shorter latency is better
longer wait time deserves priority
same language is better
previously matched pair is worse
This separation prevents accidental policy confusion. A soft preference should not silently become a hard rejection.
Relaxing Constraints Over Time
Matchmaking often relaxes rules as users wait.
A new player may initially require:
rating gap <= 100
same region
latency <= 50 ms
After waiting 60 seconds:
rating gap <= 250
same region
latency <= 100 ms
After waiting 180 seconds:
rating gap <= 500
nearby region allowed
latency <= 150 ms
Implement dynamic thresholds:
def allowed_rating_gap(wait_seconds):
if wait_seconds >= 180:
return 500
if wait_seconds >= 60:
return 250
return 100
Use the more patient player's wait time:
def compatible_dynamic(left, right):
effective_wait = max(left.wait_seconds, right.wait_seconds)
max_gap = allowed_rating_gap(effective_wait)
if left.region != right.region:
return False
return abs(left.rating - right.rating) <= max_gap
This improves match rate while preserving quality for users who have not waited long.
Stable Matching
Some domains need stability rather than only high scores.
In stable matching, participants rank possible partners. A matching is stable if there is no unmatched pair that would both prefer each other over their current assignments.
This is useful for:
students and schools
residents and hospitals
mentors and mentees
reviewers and papers
A simplified Gale-Shapley implementation:
from collections import deque
def stable_matching(proposer_preferences, receiver_preferences):
free = deque(proposer_preferences.keys())
next_choice = {
proposer: 0
for proposer in proposer_preferences
}
current_match = {}
receiver_rank = {
receiver: {
proposer: rank
for rank, proposer in enumerate(preferences)
}
for receiver, preferences in receiver_preferences.items()
}
while free:
proposer = free.popleft()
preferences = proposer_preferences[proposer]
if next_choice[proposer] >= len(preferences):
continue
receiver = preferences[next_choice[proposer]]
next_choice[proposer] += 1
if receiver not in current_match:
current_match[receiver] = proposer
continue
incumbent = current_match[receiver]
if receiver_rank[receiver][proposer] < receiver_rank[receiver][incumbent]:
current_match[receiver] = proposer
free.append(incumbent)
else:
free.append(proposer)
return {
proposer: receiver
for receiver, proposer in current_match.items()
}
Stable matching optimizes for stability, not necessarily total score. Choose it when participants have ordered preferences and blocking pairs matter.
Bipartite Assignment
Some matchmaking problems are bipartite:
workers -> tasks
drivers -> riders
reviewers -> papers
students -> projects
Each match has a cost or score. The goal is to maximize total score or minimize total cost.
For small cases, brute force is acceptable.
import itertools
def best_assignment(left_items, right_items, score):
best = None
best_score = -math.inf
for permutation in itertools.permutations(right_items, len(left_items)):
total = 0
pairs = []
for left, right in zip(left_items, permutation):
value = score(left, right)
total += value
pairs.append((left, right))
if total > best_score:
best_score = total
best = pairs
return best, best_score
This is factorial time and only suitable for tiny examples. Production systems use algorithms such as the Hungarian algorithm, min-cost max-flow, or specialized optimization solvers.
Min-Cost Flow Model
Many real matchmaking systems fit a flow model.
source -> participants -> opportunities -> sink
Edges encode eligibility and costs.
Examples:
| Constraint | Flow encoding |
|---|---|
| Each user gets at most one match | Capacity 1 |
| Task needs three workers | Capacity 3 |
| Reviewer can review five papers | Capacity 5 |
| Match quality | Edge cost |
| Hard incompatibility | No edge |
This is more complex than greedy matching, but it supports capacity constraints and global optimality.
Use greedy matching when simplicity and latency matter. Use flow or assignment when global quality and fairness matter.
Queue-Based Matchmaking
Online matchmaking works over a waiting pool.
class MatchmakingQueue:
def __init__(self):
self.players = {}
def join(self, player):
self.players[player.id] = player
def leave(self, player_id):
self.players.pop(player_id, None)
def match_once(self):
matches = greedy_match(list(self.players.values()))
for match in matches:
self.players.pop(match["left"], None)
self.players.pop(match["right"], None)
return matches
In a real service, wait time should update continuously. Store join timestamps instead of static wait seconds.
@dataclass(frozen=True)
class QueuedPlayer:
id: str
rating: int
region: str
joined_at: float
Compute wait time at match time.
Fairness
A pure score maximizer may repeatedly favor easy-to-match users and leave hard-to-match users waiting.
Fairness techniques include:
| Technique | Effect |
|---|---|
| Wait-time bonus | Prioritizes patient users |
| Maximum wait target | Forces relaxation |
| Diversity constraints | Avoids homogeneous matches |
| Quotas | Ensures allocation across groups |
| Round-robin queues | Prevents starvation |
| Randomization | Reduces deterministic bias |
Fairness must be defined for the product. In games, it may mean balanced skill and low latency. In review assignment, it may mean equal workload. In hiring, it may involve legal and policy constraints that require careful review.
Avoiding Repeat Matches
Some systems should avoid pairing the same participants repeatedly.
Store recent pair history.
def pair_key(left_id, right_id):
return tuple(sorted((left_id, right_id)))
Apply a penalty:
def match_score_with_history(left, right, recent_pairs):
score = match_score(left, right)
if pair_key(left.id, right.id) in recent_pairs:
score -= 1000
return score
For a hard ban, reject the pair instead.
def compatible_with_history(left, right, recent_pairs):
if pair_key(left.id, right.id) in recent_pairs:
return False
return compatible(left, right)
Whether repetition is acceptable depends on the domain.
Group Matchmaking
Some systems need groups rather than pairs.
Example:
5 players per team
2 teams per match
A simple approach:
- Sort players by rating.
- Form a candidate group.
- Split into balanced teams.
def form_group(players, group_size):
players = sorted(
players,
key=lambda player: player.wait_seconds,
reverse=True,
)
return players[:group_size]
Split balanced teams greedily:
def split_balanced_teams(players):
ordered = sorted(
players,
key=lambda player: player.rating,
reverse=True,
)
team_a = []
team_b = []
sum_a = 0
sum_b = 0
for player in ordered:
if sum_a <= sum_b:
team_a.append(player)
sum_a += player.rating
else:
team_b.append(player)
sum_b += player.rating
return team_a, team_b
This heuristic is simple and often reasonable. Exact balanced partitioning is a combinatorial optimization problem.
Metrics
A matchmaking system needs operational and quality metrics.
Track:
| Metric | Meaning |
|---|---|
| queue_size | Waiting participants |
| match_rate | Matches per minute |
| average_wait_time | User wait experience |
| p95_wait_time | Tail latency |
| average_rating_gap | Match quality |
| region_mismatch_rate | Relaxation pressure |
| cancellation_rate | Users leaving before match |
| rematch_rate | Repeated pair frequency |
| unmatched_count | Starvation signal |
Quality metrics should be segmented by region, rating band, mode, and time of day. Aggregate averages can hide bad experiences for small groups.
Testing
Test basic pairing.
def test_greedy_match_pairs_compatible_players():
players = [
Player("A", 1000, "asia"),
Player("B", 1050, "asia"),
]
matches = greedy_match(players)
assert len(matches) == 1
assert {matches[0]["left"], matches[0]["right"]} == {"A", "B"}
Test incompatible regions.
def test_different_regions_do_not_match():
players = [
Player("A", 1000, "asia"),
Player("B", 1000, "europe"),
]
assert greedy_match(players) == []
Test rating gap.
def test_rating_gap_blocks_match():
players = [
Player("A", 1000, "asia"),
Player("B", 1500, "asia"),
]
assert greedy_match(players) == []
Test wait-based relaxation.
def test_wait_time_relaxes_rating_gap():
left = Player("A", 1000, "asia", wait_seconds=180)
right = Player("B", 1400, "asia", wait_seconds=10)
assert compatible_dynamic(left, right) is True
These tests check the policy before optimizing the matcher.
Common Bugs
The most common matchmaking bug is mixing hard constraints and soft preferences. A penalty is not the same as a rejection.
Another common bug is greedy local matching that produces poor global results. This matters when match quality is coupled across many participants.
Users can starve if wait time is not part of the score or relaxation policy.
Distributed queues can match the same user twice unless joins, leaves, and matches are atomic.
Pair history can grow without bound. Keep a time-bounded or count-bounded history.
Group matchmaking can look balanced by average rating while still producing unfair role or latency distribution.
Metrics can hide bad tail behavior. Always inspect p95 or p99 wait time, not only averages.
Recipe
Build the system in layers.
Start with a clear participant model. Separate hard compatibility checks from soft scoring. Implement greedy pair matching as a baseline. Add wait-time relaxation to avoid starvation. Add recent-pair penalties when repetition is undesirable. Use stable matching when participant preferences matter. Use assignment or flow algorithms when global optimality and capacity constraints matter. For groups, form candidate pools and then balance teams. Track queue size, wait time, match rate, quality, cancellations, and tail behavior.
The main lesson is that matchmaking is constrained optimization under time pressure. Greedy algorithms give fast useful matches, but fairness, stability, global quality, and operational safety determine whether the system behaves well in production.