Sistema de matchmaking: ELO, Glicko-2 e gerenciamento de filas
Matchmaking é o algoritmo silencioso que decide se uma partida será divertida ou frustrante. Um sistema bem projetado reúne jogadores de nível semelhante em um tempo razoável e os distribui igualmente entre as equipes. Um sistema mal projetado cria jogos desequilibrados que afastam os jogadores do jogo em alguns dias.
Títulos como Contra-ataque 2, Dota 2, Liga dos lendários e Valorante eles têm sistemas sofisticados de matchmaking que combinam algoritmos de classificação (ELO, Glicko-2, TrueSkill) com gerenciamento de filas, matchmaking baseado em habilidades (SBMM), balanceamento de equipe e otimização de latência. Neste artigo iremos construir um sistema completo, do zero matemática para implementação de produção.
O que Voce Aprendera
- Algoritmo ELO: calcolo, vantaggi e limiti
- Glicko-2: desvio de classificação e volatilidade para matchmaking preciso
- Queue management: wait time, expansion e backfill
- SBMM vs CBMM: dibattito skill vs connection-based
- Rilevamento smurfing e protezione del matchmaking
- Implementação do Redis para enfileiramento em tempo real
Fundamentos: O Sistema ELO
O sistema ELO, inventado pelo físico Arpad Elo para o xadrez na década de 1960, ainda é a base
de muitos sistemas de classificação modernos. A ideia central e simples: após cada partida, a classificação
do vencedor aumenta e o do perdedor diminui em um valor proporcional à "expectativa"
do resultado. A fórmula para a vitória esperada do jogador A contra B é:
E(A) = 1 / (1 + 10^((RB - RA)/400))
// Implementazione ELO in TypeScript
class ELOSystem {
private readonly K: number; // K-factor: velocità di cambiamento del rating
constructor(kFactor: number = 32) {
this.K = kFactor;
}
// Probabilità attesa di vittoria del giocatore A
expectedScore(ratingA: number, ratingB: number): number {
return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}
// Aggiorna i rating dopo una partita 1v1
updateRatings(
ratingA: number,
ratingB: number,
actualScoreA: number // 1 = vittoria A, 0 = vittoria B, 0.5 = pareggio
): { newRatingA: number; newRatingB: number } {
const expectedA = this.expectedScore(ratingA, ratingB);
const expectedB = 1 - expectedA;
const actualScoreB = 1 - actualScoreA;
const newRatingA = Math.round(ratingA + this.K * (actualScoreA - expectedA));
const newRatingB = Math.round(ratingB + this.K * (actualScoreB - expectedB));
return { newRatingA, newRatingB };
}
// K-factor dinamico basato sul numero di partite giocate
dynamicKFactor(gamesPlayed: number, currentRating: number): number {
if (gamesPlayed < 30) return 40; // Nuovo giocatore: cambia velocemente
if (currentRating > 2400) return 16; // Elite: cambia lentamente
return 32; // Standard
}
}
// Esempio di utilizzo
const elo = new ELOSystem();
// Rating 1500 vs Rating 1200: A e favorito (prob. 85%)
const result = elo.updateRatings(1500, 1200, 1); // A vince
// A guadagna pochi punti (atteso), B perde pochi punti
console.log(result); // { newRatingA: 1505, newRatingB: 1195 }
// Sorpresa: B batte A
const surprise = elo.updateRatings(1500, 1200, 0); // B vince
// A perde molti punti (sorpresa!), B guadagna molti punti
console.log(surprise); // { newRatingA: 1473, newRatingB: 1227 }
Glicko-2: Classificação com incerteza
A principal limitação do ELO é que ele trata todas as classificações como igualmente certas. Um jogador que tem jogou 1.000 jogos com classificação 1.500 deve ser considerado diferente de quem jogou apenas 5 jogos com a mesma classificação. Glicko-2, desenvolvido pelo professor Mark Glickman da Universidade de Boston, introduz dois parâmetros adicionais:
- Rating Deviation (RD): quanto siamo incerti sul rating del giocatore. RD alto = molta incertezza. Default 350 per nuovi giocatori, scende verso 50 con molte partite
- Volatility (sigma): quanto e instabile la performance del giocatore. Alta volatilita = prestazioni imprevedibili
Glicko-2 e usato da CS:GO/CS2, Dota 2, Guild Wars 2, Splatoon 2, Lichess e molti altri titoli competitivi.
// Struttura dati Glicko-2
interface Glicko2Player {
rating: number; // Rating visibile (default: 1500)
rd: number; // Rating Deviation (default: 350 per nuovo giocatore)
volatility: number; // Volatilita sigma (default: 0.06)
lastActive: Date; // Per il decay del RD per inattivita
}
class Glicko2System {
private readonly TAU = 0.5; // Costante di sistema (0.3-1.2, riduce volatilita)
// Converti sulla scala interna di Glicko-2
toInternal(player: Glicko2Player): { mu: number; phi: number; sigma: number } {
return {
mu: (player.rating - 1500) / 173.7178,
phi: player.rd / 173.7178,
sigma: player.volatility
};
}
fromInternal(mu: number, phi: number, sigma: number): Glicko2Player {
return {
rating: Math.round(mu * 173.7178 + 1500),
rd: Math.round(phi * 173.7178),
volatility: sigma,
lastActive: new Date()
};
}
private g(phi: number): number {
return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI));
}
private E(mu: number, muJ: number, phiJ: number): number {
return 1 / (1 + Math.exp(-this.g(phiJ) * (mu - muJ)));
}
// Aggiorna il rating dopo un set di partite
update(player: Glicko2Player, results: Array<{ opponent: Glicko2Player; score: number }>): Glicko2Player {
const p = this.toInternal(player);
// Step 3: Calcola varianza stimata v
let vInverse = 0;
for (const { opponent } of results) {
const o = this.toInternal(opponent);
const gPhi = this.g(o.phi);
const eVal = this.E(p.mu, o.mu, o.phi);
vInverse += gPhi * gPhi * eVal * (1 - eVal);
}
const v = 1 / vInverse;
// Step 4: Calcola improvement delta
let delta = 0;
for (const { opponent, score } of results) {
const o = this.toInternal(opponent);
delta += this.g(o.phi) * (score - this.E(p.mu, o.mu, o.phi));
}
delta = v * delta;
// Step 5: Nuova volatilita (algoritmo Illinois iterativo)
const newSigma = this.updateVolatility(p, v, delta);
// Step 6: Nuova phi* (pre-rating RD)
const phiStar = Math.sqrt(p.phi * p.phi + newSigma * newSigma);
// Step 7: Aggiorna phi e mu
const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v);
const newMu = p.mu + newPhi * newPhi * (delta / v);
return this.fromInternal(newMu, newPhi, newSigma);
}
// Decay RD per giocatori inattivi (simula incertezza crescente)
decayForInactivity(player: Glicko2Player): Glicko2Player {
const p = this.toInternal(player);
const newPhi = Math.min(
Math.sqrt(p.phi * p.phi + p.sigma * p.sigma),
350 / 173.7178 // Massimo RD
);
return this.fromInternal(p.mu, newPhi, p.sigma);
}
private updateVolatility(
p: { mu: number; phi: number; sigma: number },
v: number,
delta: number
): number {
const a = Math.log(p.sigma * p.sigma);
const eps = 0.000001;
const f = (x: number) => {
const ex = Math.exp(x);
const phiSq = p.phi * p.phi;
return (
(ex * (delta * delta - phiSq - v - ex)) /
(2 * Math.pow(phiSq + v + ex, 2)) -
(x - a) / (this.TAU * this.TAU)
);
};
let A = a;
let B = delta * delta > p.phi * p.phi + v
? Math.log(delta * delta - p.phi * p.phi - v)
: a - this.TAU;
let fA = f(A);
let fB = f(B);
while (Math.abs(B - A) > eps) {
const C = A + (A - B) * fA / (fB - fA);
const fC = f(C);
if (fC * fB < 0) { A = B; fA = fB; }
else { fA /= 2; }
B = C; fB = fC;
}
return Math.exp(A / 2);
}
}
Gerenciamento de filas com Redis
Um algoritmo de classificação sofisticado não é suficiente. Precisamos de um sistema de filas que gerencie a espera, expandir progressivamente os critérios de correspondência e equilibrar a justiça com tempos de espera aceitáveis. Redis é a escolha ideal para filas graças aos Sorted Sets, que permitem pesquisas por intervalo classificação em O (log N + M).
// Sistema di matchmaking con Redis Sorted Set
import Redis from 'ioredis';
interface QueueEntry {
playerId: string;
rating: number;
rd: number;
regionLatencies: Record<string, number>;
joinedAt: number;
mode: string;
}
class MatchmakingQueue {
private redis: Redis;
private readonly MAX_WAIT_MS = 120_000;
constructor() {
this.redis = new Redis(process.env.REDIS_URL!);
}
async enqueue(entry: QueueEntry): Promise<void> {
const key = `mm:queue:${entry.mode}`;
await this.redis.zadd(key, entry.rating, JSON.stringify(entry));
}
async dequeue(playerId: string, mode: string): Promise<void> {
const key = `mm:queue:${mode}`;
const members = await this.redis.zrange(key, 0, -1);
for (const member of members) {
const e: QueueEntry = JSON.parse(member);
if (e.playerId === playerId) {
await this.redis.zrem(key, member);
return;
}
}
}
async findMatch(
candidate: QueueEntry,
teamSize: number = 5
): Promise<{ team1: string[]; team2: string[]; region: string } | null> {
const waitMs = Date.now() - candidate.joinedAt;
// Range di rating dinamico: si espande con l'attesa
// Radice quadrata per crescita sub-lineare (espansione graduale)
const waitRatio = Math.min(waitMs / this.MAX_WAIT_MS, 1);
const expansion = Math.sqrt(waitRatio);
const baseRange = 150;
const rdBonus = candidate.rd * 0.5; // RD alto = giocatore incerto = range più ampio
const waitBonus = expansion * 200; // Più attendi, più si allarga
const range = baseRange + rdBonus + waitBonus;
// Cerca candidati nel range di rating
const key = `mm:queue:${candidate.mode}`;
const rawCandidates = await this.redis.zrangebyscore(
key,
candidate.rating - range,
candidate.rating + range
);
const candidates = rawCandidates
.map(r => JSON.parse(r) as QueueEntry)
.filter(c => c.playerId !== candidate.playerId);
const needed = teamSize * 2 - 1; // Per un 5v5, servono 9 altri
if (candidates.length < needed) return null;
// Seleziona i migliori per qualità e latenza
const selected = this.selectOptimalGroup(candidate, candidates, needed);
const all = [candidate, ...selected];
return this.formBalancedTeams(all, teamSize);
}
private selectOptimalGroup(
anchor: QueueEntry,
pool: QueueEntry[],
count: number
): QueueEntry[] {
// Score composito: skill similarity + latenza bassa
const scored = pool.map(p => {
const skillDiff = Math.abs(p.rating - anchor.rating);
const sharedRegions = Object.keys(p.regionLatencies).filter(
r => anchor.regionLatencies[r] !== undefined
);
const avgSharedLatency = sharedRegions.length > 0
? sharedRegions.reduce((s, r) => s + p.regionLatencies[r], 0) / sharedRegions.length
: 999;
// Score basso = candidato migliore
const score = skillDiff * 0.7 + avgSharedLatency * 0.3;
return { player: p, score };
});
return scored
.sort((a, b) => a.score - b.score)
.slice(0, count)
.map(s => s.player);
}
private formBalancedTeams(
players: QueueEntry[],
teamSize: number
): { team1: string[]; team2: string[]; region: string } {
// Ordina per rating decrescente, poi distribuisci alternando
const sorted = [...players].sort((a, b) => b.rating - a.rating);
const team1: QueueEntry[] = [];
const team2: QueueEntry[] = [];
let sum1 = 0, sum2 = 0;
for (const p of sorted) {
if (team1.length < teamSize && (sum1 <= sum2 || team2.length === teamSize)) {
team1.push(p); sum1 += p.rating;
} else {
team2.push(p); sum2 += p.rating;
}
}
const region = this.findBestRegion(players);
return {
team1: team1.map(p => p.playerId),
team2: team2.map(p => p.playerId),
region
};
}
private findBestRegion(players: QueueEntry[]): string {
const regions = Object.keys(players[0].regionLatencies);
let best = regions[0];
let bestAvg = Infinity;
for (const r of regions) {
const avg = players.reduce(
(s, p) => s + (p.regionLatencies[r] ?? 999), 0
) / players.length;
if (avg < bestAvg) { bestAvg = avg; best = r; }
}
return best;
}
}
Backfill: Recarregue jogos em andamento
Quando um jogador se desconecta durante a partida, o backfill procura um substituto na fila em vez de deixar a equipe em desvantagem numérica. Requer lógica separada da combinação padrão porque deve respeitar o contexto do jogo já em andamento.
// Backfill service - ricerca sostituti per partite in corso
interface BackfillRequest {
sessionId: string;
slotsNeeded: number;
currentPlayers: QueueEntry[];
elapsedMs: number; // Quanto e durata la partita gia
}
class BackfillService {
async findSubstitutes(
request: BackfillRequest,
queue: MatchmakingQueue
): Promise<string[]> {
// Calcola rating medio della partita in corso
const avgRating = request.currentPlayers.reduce(
(s, p) => s + p.rating, 0
) / request.currentPlayers.length;
// Range più ampio per backfill (tolleranza maggiore)
const range = 300;
const key = 'mm:queue:ranked';
const redis = new Redis(process.env.REDIS_URL!);
const raw = await redis.zrangebyscore(key, avgRating - range, avgRating + range);
const candidates = raw
.map(r => JSON.parse(r) as QueueEntry)
.sort((a, b) => Math.abs(a.rating - avgRating) - Math.abs(b.rating - avgRating))
.slice(0, request.slotsNeeded);
// Notifica ai candidati - hanno 15 secondi per accettare
await this.notifyAndWaitForAcceptance(
candidates.map(c => c.playerId),
request.sessionId,
15_000
);
return candidates.map(c => c.playerId);
}
private async notifyAndWaitForAcceptance(
playerIds: string[],
sessionId: string,
timeoutMs: number
): Promise<string[]> {
// Invia notifica push a ogni giocatore candidato
const notifications = playerIds.map(id =>
this.pushService.notify(id, {
type: 'BACKFILL_INVITE',
sessionId,
timeout: timeoutMs,
message: 'Una partita in corso ha bisogno di un giocatore. Vuoi unirti?'
})
);
await Promise.all(notifications);
// Attendi conferma entro il timeout
return new Promise((resolve) => {
const accepted: string[] = [];
const timer = setTimeout(() => resolve(accepted), timeoutMs);
this.acceptanceBus.on('backfill-accept', (playerId: string) => {
if (playerIds.includes(playerId)) {
accepted.push(playerId);
if (accepted.length === playerIds.length) {
clearTimeout(timer);
resolve(accepted);
}
}
});
});
}
}
SBMM x CBMM: o debate
Il Matchmaking Baseado em Habilidades (SBMM) cria jogos equilibrados, mas pode aumentar os tempos de esperar. O Matchmaking baseado em conexão (CBMM) priorizar a latência, criando partidas potencialmente desequilibradas, mas com experiência de rede ideal. A maioria dos jogos moderno usa uma abordagem híbrida.
| Approccio | Pro | Contro | Usado por |
|---|---|---|---|
| SBMM puro | Partite bilanciate, esperienza equa | Attese lunghe, stress mentale | CS2 Ranked, Valorant |
| CBMM puro | Bassa latenza, attese brevi | Sbilanci severi, smurf problem | Casual modes |
| Ibrido SBMM+CBMM | Buon bilanciamento e latenza | Complexidade algorítmica | COD, Apex, Fortnite |
| Ranked + Casual | Modos separados para diferentes necessidades | Player base divisa | Overwatch, Rainbow Six |
Rilevamento Smurfing
Lo smurfando (jogadores experientes em contas secundárias para jogar contra iniciantes) e um dos problemas mais insidiosos na combinação. A classificação baseada em resultados converge lentamente: São necessários de 50 a 100 jogos para um smurf atingir seu verdadeiro nível. Enquanto isso, ele destrói a experiência de dezenas de jogadores inexperientes.
// Rilevamento smurf tramite analisi comportamentale
interface PlayerBehaviorProfile {
winRate: number;
avgKDA: number; // Kill-Death-Assist ratio
headShotPercent?: number; // FPS-specific
reactionTimeMs?: number;
accuracyPercent?: number;
mvpRate: number; // % partite come MVP
gamesPlayed: number;
rankProgression: number[]; // Storico rating ultimi 20 match
}
class SmurfDetectionService {
isLikelySmurf(profile: PlayerBehaviorProfile): { likely: boolean; confidence: number } {
let suspicionScore = 0;
let maxScore = 0;
// Check 1: Account nuovo con statistiche da veterano
if (profile.gamesPlayed < 25) {
maxScore += 30;
if (profile.winRate > 0.70) suspicionScore += 20;
if (profile.mvpRate > 0.40) suspicionScore += 10;
}
// Check 2: Performance outlier rispetto al rating assegnato
maxScore += 25;
if (profile.avgKDA > 3.5) suspicionScore += 15;
if (profile.headShotPercent && profile.headShotPercent > 60) suspicionScore += 10;
// Check 3: Curva di apprendimento assente
// Un vero principiante migliora gradualmente; uno smurf e subito al massimo
maxScore += 20;
if (profile.rankProgression.length >= 5) {
const variance = this.calculateVariance(profile.rankProgression);
const meanRank = profile.rankProgression.reduce((s, r) => s + r, 0) / profile.rankProgression.length;
const coeffVar = Math.sqrt(variance) / meanRank;
// Bassa varianza nei primi match = giocatore che non sta "imparando" = smurf
if (coeffVar < 0.05 && profile.gamesPlayed < 20) suspicionScore += 20;
}
const confidence = suspicionScore / maxScore;
return { likely: confidence > 0.6, confidence };
}
// Applica boost al K-factor per correggere velocemente il rating
getRatingBoostMultiplier(smurfConfidence: number, gamesPlayed: number): number {
if (smurfConfidence > 0.8) return 4.0; // Alto sospetto: sale velocissimo
if (smurfConfidence > 0.6) return 2.5; // Medio sospetto
if (gamesPlayed < 10) return 2.0; // Placement fase normale
return 1.0;
}
private calculateVariance(values: number[]): number {
const mean = values.reduce((s, v) => s + v, 0) / values.length;
return values.reduce((s, v) => s + Math.pow(v - mean, 2), 0) / values.length;
}
}
Métricas do Sistema de Matchmaking
Como você mede se seu matchmaking está funcionando bem? Estes são os principais indicadores a monitorizar:
| Metrica | Fórmula / Definição | Target Ideale |
|---|---|---|
| Match Quality Score | 1 - |avg_team1 - avg_team2| / avg_globale | > 0.85 |
| Wait Time P95 | 95° percentile del tempo in queue | < 60 secondi |
| Early Quit Rate | % de jogos abandonados nos primeiros 20% | < 5% |
| Win Rate Balance | Distância da taxa de vitória da equipe de 50% | < 5 punti percentuali |
| Avg Server Latency | Latenza media al game server | < 60ms |
| Queue Fill Rate | % de solicitações correspondentes em até 2 minutos | > 95% |
Melhores práticas para matchmaking
- Correspondências de colocação: Use de 5 a 10 jogos iniciais com alto fator K para posicionar rapidamente novos jogadores
- Decadência RD para inatividade: Aumenta o desvio de classificação para jogadores que ficaram inativos por mais de 2 semanas
- Classificação ponderada da equipe: Para jogos em equipe, use a classificação média ponderada, não a média simples
- Monitore o tamanho da fila: Se a fila ficar vazia (poucos jogadores online), relaxe os critérios agressivamente
- Classificação separada e casual: Modos com lógicas diferentes protegem a experiência de ambos os tipos de jogadores
Conclusoes
Um sistema de matchmaking eficaz requer compreensão matemática dos algoritmos de classificação, gerenciamento inteligente de filas que equilibra tempos de espera e qualidade, e proteções contra mau comportamento, como smurfing. Glicko-2 representa o melhor equilíbrio de precisão e implementabilidade para a maioria dos jogos competitivos modernos.
No próximo artigo exploraremos o sincronização de estado em tempo real: rollback netcode, previsão de cliente e reconciliação de servidor para criar uma experiência de jogo funciona sem problemas mesmo com latência de rede significativa.
Proximos na Serie Game Backend
- Artigo 04: Sincronização de estado em tempo real e reversão de Netcode
- Articolo 05: Architettura Anti-Cheat Server-Side







