@fehmouna/glicko2-ts
v0.1.0
Published
Type-safe, zero-dependency TypeScript implementation of the Glicko-2 rating system.
Maintainers
Readme
glicko2-ts
A type-safe, zero-dependency TypeScript implementation of the Glicko-2 rating system.
import { Glicko2 } from '@fehmouna/glicko2-ts';
const engine = new Glicko2({ tau: 0.5 });
const alice = engine.makeRating(); // 1500 / 350 / 0.06
const bob = engine.makeRating(1600, 150);
const next = engine.rate(alice, [{ opponent: bob, score: 1 }]);
// next: { rating: ~1614, rd: ~290, volatility: ~0.0600 }Why Glicko-2?
Elo has one number per player. Glicko-2 has three: a rating, an uncertainty about that rating (the rating deviation, RD), and a volatility describing how erratic the player's performance has been.
The practical wins this buys you:
- Cold-start handling. A new player has a wide RD (350 by convention), so the engine moves their rating aggressively after the first few matches and then settles. Elo can't distinguish between "1500 because they're average" and "1500 because we have no idea."
- Time decay built-in. Inactivity expands a player's RD automatically — they don't have to lose to drop out of "trusted" status.
- Volatility-aware updates. Surprising results temporarily widen the rating updates, then narrow them again as the player's true skill becomes clearer.
Glicko-2 was developed by Mark Glickman (Department of Statistics, Harvard University) and powers rating systems for chess (Australian Chess Federation), competitive video games, and skill-based matchmaking systems.
Mathematical foundation
Each player is represented as $(\mu, \phi, \sigma)$ on the Glicko-2 internal scale, where $\mu = (r - 1500)/173.7178$ and $\phi = \text{RD}/173.7178$. After a rating period in which the player faces opponents $j = 1, \ldots, m$ with results $s_j \in [0, 1]$:
The estimated variance of the player's rating-only-from-outcomes:
$$ v ;=; \left[\sum_{j=1}^{m} g(\phi_j)^2 , E(\mu, \mu_j, \phi_j),\bigl(1 - E(\mu, \mu_j, \phi_j)\bigr)\right]^{-1} $$
The estimated improvement in rating:
$$ \Delta ;=; v \sum_{j=1}^{m} g(\phi_j),\bigl(s_j - E(\mu, \mu_j, \phi_j)\bigr) $$
The new volatility $\sigma'$ is the solution to a non-linear equation in $\log \sigma^2$, solved by the Illinois algorithm (a modified regula falsi):
$$ f(x) ;=; \frac{e^x\bigl(\Delta^2 - \phi^2 - v - e^x\bigr)}{2\bigl(\phi^2 + v + e^x\bigr)^2} ;-; \frac{x - \ln \sigma^2}{\tau^2} $$
Finally, $\phi^* = \sqrt{\phi^2 + \sigma'^2}$, $\phi' = 1/\sqrt{1/\phi^{*2} + 1/v}$, and $\mu' = \mu + \phi'^2 \sum_j g(\phi_j)\bigl(s_j - E(\mu, \mu_j, \phi_j)\bigr)$.
The weighting and expected-score functions are:
$$ g(\phi) = \frac{1}{\sqrt{1 + 3\phi^2 / \pi^2}}, \qquad E(\mu, \mu_j, \phi_j) = \frac{1}{1 + \exp\bigl(-g(\phi_j),(\mu - \mu_j)\bigr)} $$
Full derivation: Glickman, M. (2013). "Example of the Glicko-2 system".
Installation
pnpm add @fehmouna/glicko2-ts
# or: npm i @fehmouna/glicko2-ts
# or: yarn add @fehmouna/glicko2-tsRequires Node 18+. Ships ESM + CJS + .d.ts out of the box. No runtime dependencies.
Quickstart
import { Glicko2 } from '@fehmouna/glicko2-ts';
// One engine per league/game. τ controls how reactive volatility is.
const engine = new Glicko2({ tau: 0.5 });
// Create ratings. Defaults: rating=1500, rd=350, volatility=0.06.
const alice = engine.makeRating();
const bob = engine.makeRating(1600, 150); // partial overrides
// Pure: compute the new Rating without mutating `alice`.
const aliceAfter = engine.rate(alice, [
{ opponent: bob, score: 1 }, // alice wins
]);
// Batch / atomic: update many players at once. Each player's update reads
// every other player's *pre-period* values, then mutates each Rating in place.
engine.updatePeriod([
{ player: alice, matches: [{ opponent: bob, score: 1 }] },
{ player: bob, matches: [{ opponent: alice, score: 0 }] },
]);
// Probability that one player beats another (uses the opponent's RD).
const pAliceWins = engine.expectedScore(alice, bob); // ≈ 0.5..1API
class Glicko2
new Glicko2(config?: Partial<Glicko2Config>)| Field | Default | Meaning |
| ------------------- | ------- | ---------------------------------------------------------- |
| tau | 0.5 | Volatility constraint τ; Glickman recommends [0.3, 1.2]. |
| defaultRating | 1500 | Starting rating for new players (and the algorithm's reference point). |
| defaultRD | 350 | Starting RD; also the upper bound any player's RD can take. |
| defaultVolatility | 0.06 | Starting volatility σ. |
| epsilon | 1e-6 | Illinois algorithm convergence threshold. |
| maxIterations | 100 | Safety cap on Illinois iterations (typically converges in <10). |
makeRating(rating?, rd?, volatility?): Rating
Construct a Rating, filling unspecified fields with the engine's defaults.
rate(player, matches): Rating — pure
Compute the new rating from a player's pre-period values and the matches they played. Does not
mutate player or any opponent. Throws RangeError if any score ∉ [0, 1].
updatePeriod(entries): void — atomic batch mutate
Update many players at once. All updates are computed from pre-period values, then each
entry.player is mutated in place. This is the correct way to advance multiple players through
one rating period — calling rate() in a loop would corrupt later updates by reading
already-updated opponents.
expectedScore(player, opponent): number
Probability that player beats opponent, accounting for the opponent's rating deviation.
Returns a value in (0, 1).
class Rating
class Rating {
rating: number; // skill point estimate (Glicko-1 / Elo scale)
rd: number; // standard deviation of the rating estimate
volatility: number; // σ — how erratic this player's results are
constructor(rating: number, rd: number, volatility: number);
clone(): Rating;
confidenceInterval(): { low: number; high: number }; // rating ± 2·RD
}The constructor throws RangeError for non-finite ratings or non-positive RD/volatility.
Types
interface MatchResult {
opponent: Rating;
score: number; // 1 = win, 0 = loss, 0.5 = draw, anything in [0, 1] for partial credit
}
interface PeriodEntry {
player: Rating;
matches: MatchResult[];
}Exported constants
DEFAULT_RATING // 1500
DEFAULT_RD // 350
DEFAULT_VOLATILITY // 0.06
DEFAULT_TAU // 0.5
DEFAULT_EPSILON // 1e-6
DEFAULT_MAX_ITERATIONS // 100
GLICKO2_SCALE // 173.7178Validation against the Glickman paper
The canonical worked example from Glickman (2013), "Example of the Glicko-2 system":
Player at
r = 1500,RD = 200,σ = 0.06, withτ = 0.5, plays three opponents:| Opponent | rating | RD | result | | -------- | ------ | --- | ------ | | 1 | 1400 | 30 | win | | 2 | 1550 | 100 | loss | | 3 | 1700 | 300 | loss |
The paper reports new values: r ≈ 1464.06, RD ≈ 151.52, σ ≈ 0.05999.
This library reproduces those values within the precision the paper publishes them at — the
unrounded analytical answer is r = 1464.0506705…, RD = 151.51652412…, σ = 0.0599960…, all
verified in tests/glickman-paper.test.ts. The small 0.01 gap on r comes from the paper
rounding intermediate quantities to 4 decimal places when illustrating each step.
The full test suite covers:
- Glickman's worked example at both paper precision and unrounded analytical precision.
- Directional behaviour — upsets, expected wins, draws.
- RD dynamics — shrinks with play, grows during inactivity, capped at
defaultRD. - Long-run convergence — repeated winners reach T1 (>2100), repeated losers fall <1300.
updatePeriodatomicity — verifies the batch update differs from naïve sequentialrate()calls (which would read already-updated opponents).- Property-based tests (fast-check) — beating a stronger opponent always strictly raises the
rating; results are always finite;
E(A,B) + E(B,A) = 1whenever RDs match; symmetry holds. - Numerical robustness — extreme inputs (
r=3000, RD=5, σ=0.001vsr=100, RD=500).
Coverage: 100% lines / functions, 97%+ branches (enforced via Vitest v8 threshold).
Performance
Single-match rate() call, Node 20, AMD Ryzen-class CPU:
| Iterations | Total | Per op | Throughput | | ---------- | ---------- | ------------ | --------------------- | | 1,000,000 | ~465 ms | ~0.47 µs | ~2.1 M ops / sec |
Reproduce with pnpm bench. The hot path is dominated by Math.exp/Math.log inside the Illinois
loop; the algorithm typically converges in under 10 iterations.
Why a class-based API?
Two reasons:
- Atomic batch semantics need identity.
updatePeriodmust know whichRatinginstances to mutate after computing everyone's new values from pre-period state. Plain{ rating, rd, volatility }objects work, but class instances signal intent clearly. - Pure + mutate, side by side.
rate()is pure (returns a newRating),updatePeriod()mutates. The class lets the same data type carry both styles without coercion.
If you prefer fully immutable updates, use rate() exclusively and reassign — the Rating
constructor is part of the public API.
Contributing
pnpm install # install deps
pnpm test # run tests
pnpm test:coverage # coverage report (enforced ≥95%)
pnpm lint # biome
pnpm typecheck # tsc --noEmit
pnpm build # esm + cjs + d.ts
pnpm verify # all of the above
pnpm bench # micro-benchmarkIssues and PRs welcome — please ensure pnpm verify is green before opening a PR.
License
MIT © Adam Fehmoun
Acknowledgements
- Mark Glickman for the Glicko-2 system.
- This implementation was originally extracted from TrueSight, a competitive esports tournament SaaS, and re-released here as a standalone, dependency-free library.
