MMR
MMR formula — base per TierDivisionType + LP, range per tier, inversion and competitive projection.
MMR — Estimated Matchmaking Rating
Sources:
src/main/java/com/safjnest/lol/utils/TierDivisionUtils.java:78-117,119,136·src/main/java/com/safjnest/lol/utils/RankProgressUtils.java:44·src/main/java/com/safjnest/lol/service/CompetitiveService.java:104-120
Iodid MMR is not Riot's internal MMR (never exposed). It is a deterministic continuous score derived from TierDivisionType + LP, used to order leaderboards, compute distribution and resolve contextual ranking. LP remains the value exposed in Rank.
1. TierDivisionUtils.getMmr — base + LP
src/main/java/com/safjnest/lol/utils/TierDivisionUtils.java:78-117:
public static int getMmr(TierDivisionType division, int lp) {
if (division == null || division == TierDivisionType.UNRANKED) return -1;
int base = switch (division) {
case IRON_IV -> 0;
case IRON_III -> 100;
case IRON_II -> 200;
case IRON_I -> 300;
case BRONZE_IV -> 400;
case BRONZE_III -> 500;
case BRONZE_II -> 600;
case BRONZE_I -> 700;
case SILVER_IV -> 800;
case SILVER_III -> 900;
case SILVER_II -> 1000;
case SILVER_I -> 1100;
case GOLD_IV -> 1200;
case GOLD_III -> 1300;
case GOLD_II -> 1400;
case GOLD_I -> 1500;
case PLATINUM_IV -> 1600;
case PLATINUM_III -> 1700;
case PLATINUM_II -> 1800;
case PLATINUM_I -> 1900;
case EMERALD_IV -> 2000;
case EMERALD_III -> 2100;
case EMERALD_II -> 2200;
case EMERALD_I -> 2300;
case DIAMOND_IV -> 2400;
case DIAMOND_III -> 2500;
case DIAMOND_II -> 2600;
case DIAMOND_I -> 2700;
case MASTER_I -> 10000;
case GRANDMASTER_I -> 20000;
case CHALLENGER_I -> 30000;
default -> -1;
};
return base < 0 ? -1 : base + Math.max(lp, 0);
}
Rules:
UNRANKEDornull→-1(sentinel, excluded from leaderboards).- Each lower division is +100 over the previous; the Diamond I → Master I jump is +7300 to clearly separate Apex tiers.
- Negative
LPis clamped to0viaMath.max(lp, 0)— prevents MMR < base on negative LP (decay or inconsistent API).
Base table (LP=0)
| TierDivision | MMR base | LP 0 | LP 50 | LP 100 |
|---|---|---|---|---|
| IRON_IV | 0 | 0 | 50 | 100 |
| IRON_III | 100 | 100 | 150 | 200 |
| BRONZE_IV | 400 | 400 | 450 | 500 |
| SILVER_IV | 800 | 800 | 850 | 900 |
| GOLD_IV | 1200 | 1200 | 1250 | 1300 |
| PLATINUM_IV | 1600 | 1600 | 1650 | 1700 |
| EMERALD_IV | 2000 | 2000 | 2050 | 2100 |
| DIAMOND_I | 2700 | 2700 | 2750 | 2800 |
| MASTER_I | 10000 | 10000 | 10050 | 10100 |
| GRANDMASTER_I | 20000 | 20000 | 20050 | 20100 |
| CHALLENGER_I | 30000 | 30000 | 30050 | 30100 |
Example CHALLENGER 500 LP → 30000 + 500 = 30500.
2. MmrRange and getMmrRange
src/main/java/com/safjnest/lol/utils/TierDivisionUtils.java:18,119-134:
public record MmrRange(int minimum, Integer maximum) {}
public static MmrRange getMmrRange(TierType tier) {
return switch (tier) {
case IRON -> new MmrRange(0, 400);
case BRONZE -> new MmrRange(400, 800);
case SILVER -> new MmrRange(800, 1200);
case GOLD -> new MmrRange(1200, 1600);
case PLATINUM -> new MmrRange(1600, 2000);
case EMERALD -> new MmrRange(2000, 2400);
case DIAMOND -> new MmrRange(2400, 10000);
case MASTER -> new MmrRange(10000, 20000);
case GRANDMASTER -> new MmrRange(20000, 30000);
case CHALLENGER -> new MmrRange(30000, null);
case UNRANKED -> new MmrRange(-1, 0);
};
}
Intervals are closed on the left, open on the right [min, max). CHALLENGER has maximum = null (unbounded above). DIAMOND covers [2400, 10000) — includes all 4 Diamond divisions + LP. UNRANKED is [-1, 0).
Used for:
competitivequery —competitiveFilter(rank, queue, region)translatesTierType→ MMR range (src/main/java/com/safjnest/nosql/MongoDB.java:1905).- Leaderboard distribution — buckets per
TierType(MongoDB.findRankDistribution).
3. getTierFromMmr — inversion
src/main/java/com/safjnest/lol/utils/TierDivisionUtils.java:136-143:
public static TierType getTierFromMmr(long mmr) {
for (TierType tier : TierType.values()) {
if (tier == TierType.UNRANKED) continue;
MmrRange range = getMmrRange(tier);
if (mmr >= range.minimum() && (range.maximum() == null || mmr < range.maximum())) return tier;
}
return TierType.UNRANKED;
}
Iterates TierType.values() and returns the first tier where mmr ∈ range. For mmr < 0 returns UNRANKED. Deterministic: getTierFromMmr(getMmr(division, lp)) == division.getTier() for every valid division.
4. RankProgressUtils.calculateGain — SoloQ gain
src/main/java/com/safjnest/lol/utils/RankProgressUtils.java:44-57:
public static int calculateGain(GameQueueType queue, RankProgress current, RankProgress previous) {
if (!GameQueueTypeUtils.isRankedSolo(queue) || !hasCurrentSnapshot(current) || !hasCurrentSnapshot(previous)) return 0;
if (current.rank == TierDivisionType.UNRANKED) return 0;
if (previous.rank == TierDivisionType.UNRANKED) return current.lp;
boolean promotionToMaster = previous.rank == TierDivisionType.DIAMOND_I && current.rank == TierDivisionType.MASTER_I;
boolean masterPlus = current.rank == TierDivisionType.MASTER_I || current.rank == TierDivisionType.GRANDMASTER_I
|| current.rank == TierDivisionType.CHALLENGER_I;
if ((promotionToMaster || !masterPlus) && current.rank != previous.rank) {
int gain = 100 - Math.abs(current.lp - previous.lp);
return current.rank.ordinal() < previous.rank.ordinal() ? gain : -gain;
}
return current.lp - previous.lp;
}
Logic only for RANKED_SOLO_5X5 (GameQueueTypeUtils.isRankedSolo, :48 → TEAM_BUILDER_RANKED_SOLO normalized to RANKED_SOLO_5X5):
| Case | Formula | Example |
|---|---|---|
| same tier/division (intra-division) | currentLp - previousLp | 60 → 80 = +20 |
| promotion/demotion non-Master | `100 - | lpDiff |
| demotion non-Master | `-(100 - | lpDiff |
| promotion Diamond I → Master I | as above (promotionToMaster case) | D1 90 → Master 20 = `100 - |
| Master/Grandmaster/Challenger intra-Apex | currentLp - previousLp (pure LP) | Master 100 → Master 80 = -20 |
| placement (previous UNRANKED) | current.lp | UNRANKED → SILVER_IV 30 = +30 |
| non-soloQ or missing snapshot | 0 | — |
The 100 - |lpDiff| models the virtual 100 LP refill on division change.
5. CompetitiveService.entry — MMR projection
src/main/java/com/safjnest/lol/service/CompetitiveService.java:104-120:
static CompetitiveEntry entry(String puuid, LeagueShard shard, Map<GameQueueType, Rank> ranks,
ProfileStatistics statistics, GameQueueType queue, long now) {
GameQueueType canonicalQueue = GameQueueTypeUtils.canonicalQueue(queue);
Rank rank = ranks == null ? null : ranks.get(canonicalQueue);
LaneType primary = primary(statistics, CanonicalQueue.from(canonicalQueue));
Integer otpChampionId = otpChampion(statistics, CanonicalQueue.from(canonicalQueue));
if (rank == null || rank.tier() == null) return null;
long mmr = TierDivisionUtils.getMmr(rank.tier(), rank.lp());
if (mmr < 0) return null;
return new CompetitiveEntry(puuid, shard, canonicalQueue, rank.tier(), mmr, primary, otpChampionId, now);
}
Each competitive row (_id = sha256(puuid:queue)[0..16], MongoDB.competitiveId:3218) contains mmr = getMmr(tierDivision, lp). UNRANKED / mmr<0 produces no row. primary and otpChampionId derive from canonical ProfileStatistics for role/OTP indexing; if missing the row remains valid but not filterable by those predicates.
6. Invariants
getMmris total and monotonic onTierDivisionType.ordinal()+ LP — guarantees stablemmr DESC, _id DESCsort for leaderboard (MongoDB.findLeaderboardPage:1559).getMmrRangeandgetTierFromMmrare exact inverses on tiers; Apex has width 10000 to contain unbounded LP.calculateGaindoes not change MMR — it is display-onlyrankProgress.gain.competitive.mmris the sole truth for ordering;Rank.lpis never used for sorting.
