前六章回答的是「一个玩家有多强、要花多久、花多少钱」。从这一章起换一个维度:一个玩家会被丢进什么样的对局里。匹配系统决定了「跟谁打」,而「跟谁打」直接决定了玩家的胜率曲线、挫败感和留存。
站内已有一篇 匹配与排名系统机制 ,它是一份机制说明清单——讲匹配要平衡公平、速度、成长、情绪和规模,罗列了分段、连败保护、组队匹配这些机制叫什么、为什么存在。这一章不重复那份清单,而是把它里面提到的两个评分模型——ELO 与 Glicko——拆开,用可跑的仿真回答一个具体问题:这两个分数离玩家的真实实力到底差多远,要打多少局才收敛。
匹配分的第一个问题:一个数字够不够
任何匹配系统的地基都是一个「分数」:系统给每个玩家记一个数,数值相近的玩家配到一起。最朴素的做法是胜场数或胜率,但这两个都坏在一个地方——它们不区分「赢的是谁」。赢 10 场菜鸟和赢 10 场高手,系统眼里是一样的。
ELO 的贡献是把「对手的强度」折进每一次更新里:赢一个比你强的人,涨分多;赢一个比你弱的人,涨分少。 这就把一个纯计数变成了对「实力」的估计。
ELO:一个分数,一条期望公式
ELO 的全部数学只有两行。给定玩家 A 的分 R_A 和玩家 B 的分 R_B,A 的期望得分(0 到 1 之间)是:
E_A = 1 / (1 + 10^((R_B - R_A) / 400))
一局打完,A 实际拿到 S_A(赢 1、平 0.5、输 0),A 的新分是:
R_A' = R_A + K * (S_A - E_A)
K 是「一步走多远」。它有两个后果:
- K 大:新分对每一局反应剧烈,收敛快,但抖动大——高手偶尔输一把会掉很多分。
- K 小:平稳,但对新玩家的初始误差修正太慢。
工程上常见的折中是定级赛:前若干局用一个大 K(本机取 64),让新人迅速离开初始分,之后降到常规 K(32)。这条规则直接写进配置里,仿真也会用到。
Glicko:再带一个不确定度
ELO 只有一个数,它无法区分两种玩家:打了 500 局、分数稳定在 1500 的老手,和刚打 3 局、碰巧也到 1500 的新人。系统对两者的信心是一样的,这不对。
Glicko 给每个玩家再挂一个数:评分偏差 RD(rating deviation,不确定度)。RD 大表示「这个分我不太确定」,于是:
- 更新幅度按
RD缩放——RD大就多动,RD小就少动; - 每局之后
RD变小(信息变多,更确定); - 长期不活跃
RD会回涨(实力可能变了)。
一个玩家新来时 RD 高(本机取 350),打得多就降下来。这正是「定级赛」机制的数学版本:不是硬编码前 N 局用大 K,而是让不确定度自己决定更新幅度。
S 层:两种模型的收敛对照
下面这段是 S 层仿真脚本,读 config/n07_match.json,造一批「真实实力」已知的玩家,让他们反复对局,同时用 ELO 与 Glicko 更新评分,逐轮记录评分与真实实力的 RMSE。
# file: sim/n07_elo.py
# tier: S
# verify: python3 sim/n07_elo.py --config config/ --out out/
"""匹配分模型与收敛(7.1):ELO 与 Glicko 在同一批玩家上的收敛对照。
固定一批「真实实力」玩家(trueSkill),反复两两对局:真实胜负由 trueSkill 的 logistic
决定,评分则由 ELO / Glicko 各自更新。看两种模型的分数离真实实力差多远(RMSE),
以及要多少局才收敛。
产出:out/n07_elo.json + out/n07_elo.csv + out/n07_elo.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import sys
import xml.etree.ElementTree as ET
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from svg_line import polyline_points # noqa: E402
SVG_NS = "http://www.w3.org/2000/svg"
Q = math.log(10.0) / 400.0 # Glicko 的 q 常数(脚本内部浮点,不进 config)
def mulberry32(seed):
"""确定性 PRNG:与 proto/src/n07_match.ts 逐位一致。"""
state = seed & 0xFFFFFFFF
def nxt():
nonlocal state
state = (state + 0x6D2B79F5) & 0xFFFFFFFF
t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF
t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
return nxt
def load(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def expected_score(ra, rb, scale_den=400):
"""ELO 期望得分 E_A = 1 / (1 + 10^((R_B - R_A)/400))。"""
return 1.0 / (1.0 + 10.0 ** ((rb - ra) / scale_den))
def glicko_update(ra, rda, rb, rdb, s, min_rd):
"""Glicko-1 单局更新:返回 (新分, 新 RD)。"""
g = 1.0 / math.sqrt(1.0 + 3.0 * Q * Q * rdb * rdb / (math.pi ** 2))
ea = 1.0 / (1.0 + 10.0 ** (-g * (ra - rb) / 400.0))
d2 = 1.0 / (Q * Q * g * g * ea * (1.0 - ea))
denom = 1.0 / (rda * rda) + 1.0 / d2
new_rd = max(min_rd, math.sqrt(1.0 / denom))
return ra + (Q / denom) * g * (s - ea), new_rd
def gen_true_skills(cfg, rng):
"""真实实力:4 个均匀分布之和(近似正态)平移到中心,整数分。"""
pop = cfg["population"]
center, spread = pop["trueSkillCenter"], pop["trueSkillSpread"]
return [center + int(round((rng() + rng() + rng() + rng() - 2.0) * spread))
for _ in range(pop["players"])]
def rmse(ratings, skills):
return math.sqrt(sum((r - t) ** 2 for r, t in zip(ratings, skills)) / len(skills))
def run_population(cfg, seed):
"""跑一遍对局,返回最终评分、真实实力与逐轮 RMSE 序列。"""
rng = mulberry32(seed)
pop, elo_cfg, g_cfg = cfg["population"], cfg["elo"], cfg["glicko"]
n, rounds = pop["players"], pop["gamesPerPlayer"]
skills = gen_true_skills(cfg, rng)
elo = [float(elo_cfg["initialRating"])] * n
gr = [float(g_cfg["initialRating"])] * n
grd = [float(g_cfg["initialRd"])] * n
games = [0] * n
elo_rmse, g_rmse = [], []
for _ in range(rounds):
grd = [min(350.0, math.sqrt(rd * rd + g_cfg["cSquared"])) for rd in grd]
order = list(range(n))
for i in range(n - 1, 0, -1):
j = int(rng() * (i + 1))
order[i], order[j] = order[j], order[i]
for k in range(0, n - 1, 2):
a, b = order[k], order[k + 1]
sa = 1.0 if rng() < expected_score(skills[a], skills[b]) else 0.0
ka = elo_cfg["provisionalKFactor"] if games[a] < elo_cfg["provisionalGames"] else elo_cfg["kFactor"]
kb = elo_cfg["provisionalKFactor"] if games[b] < elo_cfg["provisionalGames"] else elo_cfg["kFactor"]
ea = expected_score(elo[a], elo[b])
elo[a] += ka * (sa - ea)
elo[b] += kb * ((1.0 - sa) - (1.0 - ea))
nr_a, nrd_a = glicko_update(gr[a], grd[a], gr[b], grd[b], sa, g_cfg["minRd"])
nr_b, nrd_b = glicko_update(gr[b], grd[b], gr[a], grd[a], 1.0 - sa, g_cfg["minRd"])
gr[a], grd[a] = nr_a, nrd_a
gr[b], grd[b] = nr_b, nrd_b
games[a] += 1
games[b] += 1
elo_rmse.append(rmse(elo, skills))
g_rmse.append(rmse(gr, skills))
return {"skills": skills, "elo": [int(round(v)) for v in elo],
"glicko": [int(round(v)) for v in gr], "glickoRd": [int(round(v)) for v in grd],
"eloRmse": elo_rmse, "glickoRmse": g_rmse}
def render_multi(path, series, title):
allv = [v for _, ys, _ in series for v in ys]
lo, hi = min(allv), max(allv)
svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 640 360", "width": "640", "height": "360"})
ET.SubElement(svg, "rect", {"width": "640", "height": "360", "fill": "#ffffff"})
for _, ys, color in series:
ET.SubElement(svg, "polyline", {"points": polyline_points(ys, lo, hi),
"fill": "none", "stroke": color, "stroke-width": "2"})
t = ET.SubElement(svg, "text", {"x": "56", "y": "16", "font-size": "13", "fill": "#111"})
t.text = title
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return path
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
ap.add_argument("--seed", type=int, default=None)
args = ap.parse_args()
cfg = load(os.path.join(args.config, "n07_match.json"))
seed = args.seed if args.seed is not None else cfg["seed"]
res = run_population(cfg, seed)
e_rmse, g_rmse = res["eloRmse"], res["glickoRmse"]
e0, e_last = round(e_rmse[0], 3), round(e_rmse[-1], 3)
g0, g_last = round(g_rmse[0], 3), round(g_rmse[-1], 3)
e_conv = next((i + 1 for i, v in enumerate(e_rmse) if v <= e_last * 1.5), len(e_rmse))
g_conv = next((i + 1 for i, v in enumerate(g_rmse) if v <= g_last * 1.5), len(g_rmse))
report = {"version": 1, "seed": seed, "players": cfg["population"]["players"],
"gamesPerPlayer": cfg["population"]["gamesPerPlayer"],
"eloStartRmse": e0, "eloFinalRmse": e_last, "eloConvergeRound": e_conv,
"glickoStartRmse": g0, "glickoFinalRmse": g_last, "glickoConvergeRound": g_conv,
"glickoFinalRdMean": int(sum(res["glickoRd"]) / len(res["glickoRd"]))}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n07_elo.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "n07_elo.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["round", "eloRmse", "glickoRmse"])
for i in range(len(e_rmse)):
w.writerow([i + 1, round(e_rmse[i], 4), round(g_rmse[i], 4)])
render_multi(os.path.join(args.out, "n07_elo.svg"),
[("ELO", e_rmse, "#d1242f"), ("Glicko", g_rmse, "#1f6feb")],
f"评分 RMSE 收敛:ELO {e0}→{e_last} / Glicko {g0}→{g_last}")
print(f"ELO 第1轮 RMSE {e0} 终值 {e_last} 收敛于第 {e_conv} 轮")
print(f"Glicko 第1轮 RMSE {g0} 终值 {g_last} 收敛于第 {g_conv} 轮")
print(f"最终 Glicko RD 均值约 {report['glickoFinalRdMean']}(初值 {cfg['glicko']['initialRd']})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
配置里 2000 名玩家的真实实力中心与初始分对齐(都取 1200),所以「初始 RMSE」就是真实实力的离散程度;跑 100 局后,两种模型都收敛。本机实跑输出(同机相对量级,绝对数值不可移植):
ELO 第1轮 RMSE 167.296 终值 55.703 收敛于第 25 轮
Glicko 第1轮 RMSE 189.48 终值 47.84 收敛于第 40 轮
最终 Glicko RD 均值约 55(初值 350)
读收敛曲线
把 out/n07_elo.csv 的关键轮次摊开看:
| 轮次 | ELO RMSE | Glicko RMSE |
|---|---|---|
| 1 | 167.30 | 189.48 |
| 5 | 137.88 | 167.62 |
| 10 | 113.34 | 131.92 |
| 20 | 91.26 | 99.35 |
| 30 | 76.40 | 82.07 |
| 50 | 63.32 | 64.64 |
| 75 | 57.65 | 53.41 |
| 100 | 55.70 | 47.84 |
三个读数:
第 1 轮 Glicko 反而更差(189.5 > 167.3)。 这不是 bug——新玩家
RD = 350极高,Glicko 认为「我啥都不知道」,于是第一局更新幅度很大,一局就把分数推得很远。ELO 的K = 64相对克制。高不确定度是双刃剑:修正快,也容易过冲。大约第 30 轮,两条线交叉。 之后 Glicko 一路领先(100 轮时 47.84 对 55.70,低约 14%)。原因还是
RD:随着局数增加,RD从 350 降到均值 55,更新幅度收窄,分数不再被单局带偏,于是更贴近真实实力。两条曲线在 100 轮仍未完全走平。 ELO 从第 75 轮到第 100 轮只从 57.65 降到 55.70。这意味着即使打满 100 局,评分与真实实力之间仍有约 55 分的残差——这个残差不是实现缺陷,而是「用胜负估计实力」这件事本身的下限:每一局都是带噪声的信号。
那个 55 分残差,正是第 7.3 节要利用的东西:如果评分本身有噪声,那么「分数相近」并不等于「实力相近」。 公平性必须用仿真去量,不能想当然。
小结
这一节把匹配分从「一个数字」拆成了两条可验证的结论:
- ELO 用一个分数 + 一条期望公式:赢强者涨分多,
K决定一步走多远。 - Glicko 再带一个不确定度
RD:新人更新快、老人更新稳,RD从 350 收敛到约 55。 - 收敛要用仿真验证:100 局后 ELO 残差 55.70、Glicko 47.84,且都还没完全走平——评分天然带噪声。
有了「分数」这把尺子,下一步是把它切成段位:怎么分段、每段多少人、段位和真实实力的错位有多大。这就是 7.2。
阅读导航:上一节:目录 · 下一节:7.2 分段与段位设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。