npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fehmouna/glicko2-ts

v0.1.0

Published

Type-safe, zero-dependency TypeScript implementation of the Glicko-2 rating system.

Readme

glicko2-ts

CI License: MIT Types: Included Zero dependencies

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-ts

Requires 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..1

API

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): Ratingpure

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): voidatomic 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.7178

Validation 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.
  • updatePeriod atomicity — verifies the batch update differs from naïve sequential rate() 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) = 1 whenever RDs match; symmetry holds.
  • Numerical robustness — extreme inputs (r=3000, RD=5, σ=0.001 vs r=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:

  1. Atomic batch semantics need identity. updatePeriod must know which Rating instances to mutate after computing everyone's new values from pre-period state. Plain { rating, rd, volatility } objects work, but class instances signal intent clearly.
  2. Pure + mutate, side by side. rate() is pure (returns a new Rating), 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-benchmark

Issues 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.