Skip to content

Rating System v2 — Specification

Status: Active Date: 2026-04-03 Supersedes: elo-rating.md


Design Principles

  1. Evidence-based — Every formula element traces to published research or industry standard
  2. Amateur-first — Designed for 동호인 reality: mismatched pairings, draws, incomplete sets, RPS
  3. Gender-neutral rating, gender-aware rankings — No gender pools in the rating (one pool per format); leaderboards are gender-aware VIEWS over that rating. See §0.
  4. Global, not per-club — One rating earned across all clubs. Rank is contextual.
  5. Non-punitive — Inactivity grows uncertainty, doesn't decay rating. No "play or lose rank" pressure.

0. Rating vs Tier vs Ranking — the three layers (2026-07, owner-decided)

Canonical. Resolves the earlier gender-aware/gender-neutral split: the old rating-system-summary §11 gender-adjusted tier display is superseded. The three layers are distinct concerns:

  1. Rating (the engine) — gender-neutral. One Glicko-style rating per format (singles, doubles), earned from all matches regardless of opponent gender. No gender pools, no second ELO. Uncertainty (RD) models infrequent play, so new players read as placement (배치 중) until calibrated. Doubles credits individuals from the team result — Korean club play is overwhelmingly 복식, so this is first-class, not an afterthought.
  2. Tier (the public badge) — pure-skill, gender-neutral. The named tiers (실버~골드) map directly from the gender-neutral rating. The badge means one honest thing: ability. No gender-adjusted tier display — that path (summary §11, elo-tier-mapping Appendix D) is deprecated in favor of this pure-skill decision.
  3. Ranking (the leaderboards) — gender-aware, club-first, seasonal. Leaderboards are filtered VIEWS over the single rating: a consolidated (all-gender) board and per-gender boards, each per format. Club-scoped is primary; quarterly seasons reset standings (plus an all-time board). Rank may carry a light participation + manner nudge (never enough to leapfrog a real skill gap) so it rewards a good clubmate — but the tier stays pure-skill. Mixed-doubles boards are consolidated-only (inherently cross-gender).

Implementation note (not yet built): keeps the gender-neutral Glicko rating + named-tier mapping already specced here; deprecates getGenderAdjustedTier() (the gender-percentile display path); adds a leaderboard scope dimension (overall | men | women × format × season). Owner-decided 2026-07: named tiers, pure-skill tier, quarterly seasons.


1. Rating Pools

2 pools, both global and gender-neutral:

PoolWhat countsSeparate because
Singles ELOAny 1v1 matchIndividual skill, no partner variable
Doubles ELOAny 2v2 match (same-gender, mixed, open)Partner coordination is a separate skill

No mixed doubles sub-pool. No gender-based sub-pools. This matches UTR and WTN.

Each player has:

singlesElo: number     (default: from onboarding seed)
singlesRD: number      (default: 350)
doublesElo: number     (default: from onboarding seed)
doublesRD: number      (default: 350)

2. Core Formula

Singles

delta = K × (actual - expected) × scoreFactor × outcomeWeight

Doubles

effective_team_A = avg(r1, r2) - α × |r1 - r2|
effective_team_B = avg(r3, r4) - α × |r3 - r4|

expected = 1 / (1 + 10^(-(effective_team_A - effective_team_B) / 400))

delta = K × (actual - expected) × scoreFactor × outcomeWeight

Both teammates receive the FULL delta (doublesWeight = 1.0). Source: UTR gives identical delta to both partners.

α (team variance penalty) = 0.08

Team CompositionAvgPenaltyEffective
1400 + 1400140001400
1600 + 12001400-321368
1800 + 10001400-641336

3. Match Outcomes

OutcomeactualoutcomeWeightWhenRating update?
decisive (win)1.01.0Normal win with final scoreYes
decisive (loss)0.01.0Normal lossYes
draw0.51.0Set ended tied, no tiebreak (4-4, 5-5, etc.)Yes — draws ARE informative (chess standard)
incomplete (leader)0.60.5Stopped early, one side ahead (3-4, etc.)Yes — partial signal
incomplete (tied)0.50.3Stopped early, score tied (3-3, etc.)Yes — minimal signal
abandoned0Weather, injury, external factorNo
non_competitive0Rock-paper-scissors, coin flipNo
walkover0Opponent no-showNo

Why draws get full weight (1.0, not 0.7): In chess ELO (the origin system), draws use actual = 0.5 with full weight. A draw against a stronger opponent IS informative — it tells us the weaker player performed better than expected. Reducing weight would discard real information. Source: FIDE Elo handbook, Lichess implementation.


4. Score Factor (Margin of Victory)

Based on Kovalchik (2020) — margin-of-victory ELO extensions outperformed standard ELO on ATP/WTA data.

gamePercentage = gamesWon / totalGames

scoreFactor:
  gamePercentage >= 0.75 → 1.5 (dominant)
  gamePercentage >= 0.60 → 1.2 (clear)
  otherwise             → 1.0 (close)

Format-specific caps:

  • Standard match (1+ sets): full range 1.0–1.5
  • Tiebreak-only format (super-tiebreak as entire match): cap at 1.2

Special cases:

  • Retirement/walkover: scoreFactor = 1.0 (no margin data)
  • Draw: scoreFactor = 1.0 (no winner to reward)
  • Incomplete: scoreFactor = 1.0 (insufficient data for margin)

5. Adaptive K-Factor

ConditionKSource
First 20 career matches (provisional)40USCF K-factor system
Established player (20+ matches)32Standard ELO
High-rated player (Diamond+, 2100+)24FIDE K-factor for 2400+

Upset bonus: When underdog wins, K × 1.5 (mild boost). Source: conceptually aligned with UTR's full-weight upset exception, but moderated to prevent rating explosion.

No gap-based K scaling. The ELO expected score formula already handles mismatches mathematically — (actual - expected) approaches 0 for extreme favorites. Adding K scaling on top double-counts the correction. Source: FiveThirtyEight Elo, Tennis Abstract Elo — neither uses gap-based K.


6. Inactivity Handling (Glicko-2 Inspired)

No rating decay. Rating number stays constant during inactivity. Instead, uncertainty (RD) grows:

Monthly RD growth: new_RD = min(350, sqrt(old_RD² + 15²))
Inactive DurationStarting RD 50 →Starting RD 100 →
1 month52101
3 months58106
6 months68118
12 months95150

After returning: RD shrinks with each match played:

new_RD = max(30, old_RD × 0.92)  // ~8% reduction per match

Display: Show rating ± RD for users who want precision: "1,450 ± 65" Default display: just the number "1,450" — RD is a background signal.

Why not rating decay: Moving ratings toward average punishes seasonal players (Korean winter/summer breaks). The Toss UX benchmark: never punish users for living their life. Glicko-2's RD growth is the industry standard (Lichess, Chess.com, Dota 2). Source: Glickman (2001).


7. Global Pool, Club-Contextual Display

One global rating per format (singles + doubles). Not per-club.

Display in club context:

My Doubles Rating: 1,450 (Gold II)
  Club A rank: #3 of 20 members
  Club B rank: #12 of 35 members

Why global:

  • Per-club ratings diverge and become meaningless for inter-club play
  • Discovery/matchmaking requires comparable numbers
  • UTR, WTN, NTRP are all global (not venue-specific)
  • Cross-club calibration happens naturally as members play in multiple clubs

8. Safety Mechanisms

Rating Floor

A player's rating cannot drop below 80% of their personal all-time peak:

floor = peak_rating × 0.80
new_rating = max(floor, computed_new_rating)

Prevents catastrophic rating loss from a bad streak or system exploitation.

Inflation Monitor

Check pool mean quarterly. If it drifts > 50 points from the seed default (1000), apply a global correction:

correction = pool_mean - 1000
new_rating = old_rating - correction  (for all players)

This is standard ELO pool maintenance (USCF, FIDE).

New Player Seeding

New players seed from self-assessment using Korean tennis division terminology. Seeds ensure every tier gets populated — from Bronze III up.

Self-AssessmentKorean DivSeedStarting TierNTRP
테린이 (Complete Beginner)500Bronze III1.5-2.0
입문 (Beginner)테린이/입문700Bronze I2.0-2.5
신인부 (Newcomer)신인부950Silver II2.5-3.0
오픈부 (Open)오픈부1200Gold III3.0-3.5
챌린저부 (Challenger)챌린저부1550Gold I3.5-4.0
마스터즈부 · 선수출신마스터즈부2100Diamond III4.5+

Distribution design: Korean 동호인 bulk self-selects 초급/중급 (seeds 950/1200). The 테린이 option ensures Bronze III is populated from day one. Provisional K=40 for first 20 matches ensures fast convergence to true level regardless of seed accuracy.


9. Complete Calculation Example

Scenario: Doubles match. Team A (1600 + 1200) beats Team B (1400 + 1400), score 6-3.

Step 1: Effective team ratings
  Team A: avg(1600, 1200) - 0.08 × |1600-1200| = 1400 - 32 = 1368
  Team B: avg(1400, 1400) - 0.08 × |0| = 1400

Step 2: Expected score for Team A
  expected_A = 1 / (1 + 10^(-(1368-1400)/400)) = 1 / (1 + 10^0.08) = 0.481

Step 3: Score factor
  gamePercentage = 6 / (6+3) = 0.667 → scoreFactor = 1.2

Step 4: Delta (for all 4 players)
  K = 32 (all established)
  delta = 32 × (1.0 - 0.481) × 1.2 × 1.0 = 32 × 0.519 × 1.2 = 19.9

Step 5: Apply
  Team A player 1 (1600): 1600 + 20 = 1620
  Team A player 2 (1200): 1200 + 20 = 1220
  Team B player 1 (1400): 1400 - 20 = 1380
  Team B player 2 (1400): 1400 - 20 = 1380

Step 6: Check floors
  No player dropped below 80% of peak. OK.

Scenario: Same teams, but draw (4-4, no tiebreak).

actual = 0.5, outcomeWeight = 1.0, scoreFactor = 1.0
delta = 32 × (0.5 - 0.481) × 1.0 × 1.0 = 32 × 0.019 = 0.6

// Tiny delta — draw between near-equal teams is expected. Correct.

Scenario: Same teams, but decided by RPS.

outcome = 'non_competitive' → delta = 0. No rating change.

11. Closed Pool Calibration

The Problem

TwoMore is club-centric. Most players start in a single club and may never play cross-club matches. Self-assessed seed ratings are an honor system — dishonest or miscalibrated seeds in an isolated club produce internally consistent but globally wrong ratings.

Solution: Layered Calibration

11.1 Rating Confidence Score

Each player carries a confidence score (0.0–1.0) that reflects how well-calibrated their rating is against the global pool.

confidence: number  (default: 0.0)
ActionConfidence Change
Self-assessment seed0.0 (uncalibrated)
Each intra-club match+0.02 (max contribution: 0.3)
Each cross-club match+0.05
Match vs player with confidence ≥ 0.7+0.03 bonus
6+ months inactivedecay toward 0.3 floor

Cap: 1.0. A player with 14+ cross-club matches against calibrated opponents reaches full confidence.

Display: Confidence is NOT shown to users. It's a backend signal used for:

  • Weighting match results in pool statistics
  • Triggering calibration alerts for admins
  • Informing the strike system (§11.2)

11.2 Dishonesty Detection (Strike System)

Inspired by USTA's self-rate verification. Detects players whose actual performance consistently diverges from their seed.

strike_check runs after every match where:
  - player.confidence < 0.5 (still in calibration phase)
  - player has 5+ completed matches

Dynamic ELO vs Seed ELO comparison:

seed_delta = current_elo - seed_elo
matches_played = total matches since seed

if matches_played >= 5:
  if seed_delta < -150:
    // Player seeded too high — flag as "over-seeded"
    // Action: increase K to 48 for next 10 matches (faster correction)
  if seed_delta > +200:
    // Player seeded too low (sandbagging)
    // Action: increase K to 48 for next 10 matches
    // If seed_delta > +350 after 15 matches: admin alert

No punishment. Strikes accelerate convergence, they don't penalize. A genuinely miscalibrated player benefits from faster K. Only extreme sandbagging (>350 after 15 matches) triggers an admin review.

11.3 Bridge Player Pool Offset Correction

When a player joins a second club (becoming a "bridge player"), they create a calibration link between the two club pools.

Bridge detection:
  player plays in Club A (10+ matches) AND Club B (5+ matches)
  player.confidence >= 0.5

Pool offset estimation:
  For each bridge player:
    offset_estimate = bridge_player.elo - avg(opponents_in_new_club).elo
    weighted by bridge_player.confidence

  pool_offset = weighted_median(all bridge estimates for this club pair)

Correction is passive, not retroactive:

  • Bridge data informs the expected score calculation for future cross-club matches
  • No mass rating adjustment — that would confuse users
  • When enough bridge data accumulates (3+ bridge players between two clubs), the system gains confidence in the offset

Why weighted median, not mean: Resistant to a single outlier bridge player who may be miscalibrated themselves.

11.4 Cross-Pool Match Flag

Every match record tracks whether it's cross-pool:

sql
ALTER TABLE matches ADD COLUMN is_cross_pool BOOLEAN DEFAULT false;

A match is cross-pool when participants come from different "home clubs" (the club where they have the most matches). Cross-pool matches:

  • Grant higher confidence increments (§11.1)
  • Feed the bridge player offset model (§11.3)
  • Are weighted more heavily in global pool statistics

11.5 Practical Timeline

PhaseWhenWhat
LaunchDay 1All players seed from self-assessment. confidence = 0.0
EarlyMonth 1-3Intra-club matches build internal consistency. confidence reaches ~0.3
GrowthMonth 3-6Bridge players emerge as members join multiple clubs. First pool offsets calculated
MatureMonth 6+Cross-club events and discovery matches accelerate calibration. Most active players reach confidence > 0.7

Key insight: We don't need perfect calibration at launch. The system is designed to converge over time. Intra-club ratings are immediately useful for within-club matchmaking. Global accuracy improves as the social graph expands.


12. Data Model Changes Required (Updated)

profiles table

sql
ALTER TABLE profiles ADD COLUMN singles_elo INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN singles_rd REAL DEFAULT 350;
ALTER TABLE profiles ADD COLUMN doubles_elo INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN doubles_rd REAL DEFAULT 350;
ALTER TABLE profiles ADD COLUMN singles_peak INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN doubles_peak INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN rating_confidence REAL DEFAULT 0.0;
ALTER TABLE profiles ADD COLUMN seed_singles_elo INTEGER;
ALTER TABLE profiles ADD COLUMN seed_doubles_elo INTEGER;
-- Existing elo_rating → migrate to doubles_elo (most Korean 동호인 play doubles)

matches table

sql
ALTER TABLE matches ADD COLUMN outcome TEXT DEFAULT 'decisive'
  CHECK (outcome IN ('decisive','draw','incomplete','abandoned','non_competitive','walkover'));

ALTER TABLE matches ADD COLUMN is_cross_pool BOOLEAN DEFAULT false;

ALTER TABLE matches ALTER COLUMN format TYPE TEXT;
-- Expand format to include: singles, doubles (no gender prefix needed)

elo_history table

sql
ALTER TABLE elo_history ADD COLUMN rating_pool TEXT DEFAULT 'doubles'
  CHECK (rating_pool IN ('singles', 'doubles'));

13. References

  • Kovalchik (2020) — Extension of ELO to margin of victory. International Journal of Forecasting.
  • Glickman (2001) — Glicko-2 rating system. Boston University.
  • UTR Algorithm FAQ — universaltennis.com/support
  • UTR Doubles Algorithm — universaltennis.com/support
  • FIDE Handbook — fide.com (K-factor rules)
  • USCF Rating System — uschess.org
  • FiveThirtyEight NBA ELO — fivethirtyeight.com
  • Tennis Abstract ELO (Jeff Sackmann) — tennisabstract.com
  • SliceWin ELO — slicewin.com
  • Elo Sports Challenge — elosportschallenge.wordpress.com
  • Schmidt Computer Ratings — computerratings.blogspot.com
  • USTA Self-Rate Verification — usta.com (strike system for dishonest self-ratings)
  • WTN Confidence Model — worldtennisnumber.com (confidence scoring for rating reliability)
  • IRT Test Equating — Lord & Novick (1968), applied to bridge player calibration
  • FIDE Pool Offset Correction (2024) — fide.com (pool-wide rating adjustment)
  • LoL MMR Server Merge — Riot Games (regression toward mean for pool merges)

Markdown remains the source of truth. Run yarn docs:check before handoff.