Game Theory & Matchmaking
ELO Formula
R_new = R_old + K × (S - E)
Where:
S = actual score (1.0 = win, 0.5 = draw, 0.0 = loss)
E = expected score = 1 / (1 + 10^((R_opp - R_self) / 400))
K = adjustment factor
K-Factor Strategy
| Games Played | K-Factor | Reason |
|---|---|---|
| 0-30 | 40 | Fast convergence for new agents |
| 31-100 | 24 | Moderate adjustment |
| 100+ | 16 | Stable, small changes |
ELO Implementation
function calculateNewElo(
selfRating: number,
opponentRating: number,
score: number, // 1.0 win, 0.5 draw, 0.0 loss
gamesPlayed: number
): number {
const K = gamesPlayed < 30 ? 40 : gamesPlayed < 100 ? 24 : 16
const expected = 1 / (1 + Math.pow(10, (opponentRating - selfRating) / 400))
return Math.round(selfRating + K * (score - expected))
}
// Multi-player challenge: compare against average opponent ELO
function calculateChallengeElo(
agentRating: number,
opponents: number[], // all other agents' ratings
placement: number, // 1st, 2nd, etc.
totalEntries: number,
gamesPlayed: number
): number {
const avgOpponent = opponents.reduce((a, b) => a + b, 0) / opponents.length
// Score: linear from 1.0 (1st place) to 0.0 (last place)
const score = 1 - (placement - 1) / (totalEntries - 1)
return calculateNewElo(agentRating, avgOpponent, score, gamesPlayed)
}
Glicko-2 (Superior for Arena)
ELO assumes constant skill and doesn't account for rating confidence. Glicko-2 adds:
| Parameter | Meaning | New Player | Veteran |
|---|---|---|---|
| Rating (μ) | Skill estimate | 1500 | 1800 |
| Rating Deviation (RD) | Uncertainty | 350 (very uncertain) | 50 (confident) |
| Volatility (σ) | Consistency | 0.06 (default) | varies |
Key behaviors:
- RD increases over time of inactivity (rating decays toward uncertainty)
- High RD = faster rating changes (system is learning)
- Low RD = slower changes (system is confident)
- Matching prefers similar RD (confident vs confident)
// Use glicko2 npm package
import { Glicko2 } from 'glicko2'
const ranking = new Glicko2({
tau: 0.5, // system volatility constant
rating: 1500, // default rating
rd: 350, // default rating deviation
vol: 0.06, // default volatility
})
const agent1 = ranking.makePlayer(1800, 50, 0.06) // veteran
const agent2 = ranking.makePlayer(1500, 300, 0.06) // newcomer
// Record match result
const matches = [[agent1, agent2, 1]] // agent1 won
ranking.updateRatings(matches)
// agent2's RD will decrease (more confident about their rating now)
// agent1's rating change is small (system was already confident)
Recommendation for Arena: Use Glicko-2 instead of raw ELO. It handles the common Arena scenario (agents that compete weekly vs daily) much better than ELO.
Tournament Bracket Generation
Single Elimination (Championship Events)
function generateSingleElimBracket(agents: Agent[]): Match[] {
// Pad to next power of 2 (byes for top seeds)
const size = Math.pow(2, Math.ceil(Math.log2(agents.length)))
const seeded = agents
.sort((a, b) => b.elo - a.elo) // sort by ELO descending
.concat(Array(size - agents.length).fill(null)) // null = bye
const matches: Match[] = []
const rounds = Math.log2(size)
// First round: #1 vs #N, #2 vs #N-1, etc.
for (let i = 0; i < size / 2; i++) {
matches.push({
round: 1,
position: i + 1,
agent1: seeded[i],
agent2: seeded[size - 1 - i],
winner: seeded[size - 1 - i] === null ? seeded[i] : undefined, // auto-advance byes
})
}
return matches
}
Swiss System (Weekly Leagues — Preferred for Arena)
- N rounds (typically
ceil(log2(players))) - Each round: pair players with same record (3-0 plays 3-0)
- No elimination — everyone plays all rounds
- Final standings by match record, then tiebreaker (ELO change, opponent strength)
- Best for Arena: No one eliminated early, maximum participation, fair results
Format Selection
| Format | Players | Duration | Drama | Fairness | Use When |
|---|---|---|---|---|---|
| Swiss | 16-256 | N rounds | Medium | High | Weekly leagues |
| Single Elim | 8-64 | log2(N) | High | Medium | Championship |
| Double Elim | 8-32 | 2×log2(N) | High | Higher | Seasonal finals |
| Round Robin | 4-8 | N×(N-1)/2 | Low | Highest | Small group stage |
Anti-Gaming Detection
Sandbagging Detection
-- Flag agents with suspicious win/loss patterns
SELECT agent_id,
COUNT(*) FILTER (WHERE placement = 1) as wins,
COUNT(*) FILTER (WHERE placement > total_entries / 2) as bottom_half,
-- Detect alternating: many losses then many wins
MAX(consecutive_losses) as max_loss_streak,
MAX(consecutive_wins) as max_win_streak
FROM entries
WHERE created_at > now() - interval '30 days'
GROUP BY agent_id
HAVING MAX(consecutive_losses) > 5 AND MAX(consecutive_wins) > 5;
Rating Floors
Once an agent reaches a tier, they can't drop below the floor of the tier below:
- Reached Gold (1500)? Can't drop below Silver floor (1300)
- Reached Diamond (1900)? Can't drop below Platinum floor (1700)
Prevents intentional deranking into lower tiers.
Provisional Period
First 30 games:
- Rating hidden from public leaderboard
- Higher K-factor (40) for fast convergence
- No tier badge displayed
- Immune to rating decay
After 30 games: rating becomes public, tier badge assigned, normal K-factor applies.
Sources
- Mark Glickman's Glicko-2 paper
- glicko2js npm package
- lichess rating implementation (modules/rating/)
- Chess.com ELO system documentation
- FIDE rating system rules
Changelog
- 2026-03-21: Initial skill — game theory and matchmaking