A Lua Implementation of the Glicko-2 Rating Algorithm for Skill-Based Matchmaking

What is Glicko-2?

Glicko-2 is an algorithm used to assess a player’s skill level relative to others, similar to Elo in chess. It is used in many competitive multiplayer games with skill-based matchmaking, such as Team Fortress 2, Splatoon 2, Lichess, and more.


How Does Glicko-2 Work?

Glicko-2 has three metrics it uses to measure skill: Rating, RD (Rating Deviation), and Volatility. Rating determines approximately a player’s playing level, RD measures how confident the system is in your rating, and Volatility is how consistent a player is in their matches.

The system updates a player’s rating on occasion, ideally after ~10-15 matches. A rating period occurs at a constant specified time after the last period, designated by the developer (ex: 1 week). If no matches occurred in that timeframe, an update is still done (I’ll explain how you can do this further down this post).


Why Glicko-2?

Compared to Elo, Glicko-2 is more accurate due to how it measures uncertainty in a player’s rating. Initially, the algorithm will be very uncertain in its score of the player, swinging wildly from period to period. As the player continues to play, the algorithm will become more certain in its predictions, and will settle down to a particular rating with low deviation, slowing down how quickly a player’s rating can change. This will home Glicko-2 to the player’s real skill level faster than Elo would.

If a player has not played any games when you update their rating, the algorithm will add a little more uncertainty to their score (the algorithm doesn’t know what you could be doing while away), to account for a player potentially “rusting”.


Documentation

Side Note: This module does NOT support rating periods as described above. This must be done yourself.

Side Note 2: Almost all functions that return a Glicko2 object returns a modified copy of the original, meaning that the functions do not modify a rating themselves. This means that you have to write your code like this:
player_glicko2 = player_glicko2:update(matches)


Glicko2 Glicko2.g1(Rating: number, RatingDeviation: number, Volatility: number)

Creates a new Glicko-2 rating, scaled to the Glicko-1 system.

Glicko2 Glicko2.g2(Rating: number, RatingDeviation: number, Volatility: number)

Creates a new Glicko-2 rating.

Glicko2 Glicko2:copy()

Returns a copy of itself.

Glicko2 Glicko2:to2()

Scales a Glicko-1 scaled rating to Glicko-2.

Glicko2 Glicko2:to1()

Scales a Glicko-2 rating to Glicko-1.

Glicko2(scored) Glicko2:score(score: number)

Scores how well a player did against their opponent. Apply this function to an opponent’s Glicko score and list it under a player’s previous matches
0: Loss | 1: Win | 0.5: Tie

Glicko2 Glicko2:update(matches: table)

Updates a Glicko-2 rating using an array of scores generated with the above function.

number, number Glicko2:range(padding: number)

Gets a range of values where the algorithm is most confident in (95%), adding the padding to it’s radius.

number, number Glicko2:deviation(deviations: number)

Gets a range of values within a specified number of standard deviations. The range function uses two standard deviations.

number, number Glicko2:percent(confidence: number)

Gets a range of values with a certain amount of confidence. The range function uses a confidence of about 0.95 (95%). Note that this function uses an approximation, so it may return slightly incorrect values.

table Glicko2.serialize(gv: Glicko2 || Glicko2(scored)) --can also be done as gv:serialize()

Serializes a Glicko-2 rating for DataStores.

Glicko2 Glicko2.deserialize(s_gv: table, version: number)

Deserializes a Glicko-2 rating. The version must be specified, as version is not preserved during serialization.

How do I implement rating periods?

A rating period should occur after a player joins the game. To check when a player’s rating can update, get the last time the player had their rating updates and compare it with the current time. If the difference is larger than the rating period timespan, perform a rating period (if the player has been away for longer, you can do it multiple times as needed to compensate).


Install Here

Roblox Model:

Lua File
Glicko2.lua (5.3 KB) [OUTDATED]

Source Code
--http://www.glicko.net/glicko/glicko2.pdf
local c = 173.7178 -- Used for conversion between Glicko1 and 2
local epsilon = 1e-6 -- Convergence

-- Shortcuts for commonly used math functions
local exp = math.exp
local sqrt = math.sqrt
local log = math.log

-- Glicko2
local Glicko2 = {
	Tau = 0.5, -- Slider for volatility
	InitialVolatility = 0.06
}; Glicko2.__index = Glicko2

-- Creates a Glicko rating with a specified version
function Glicko2.gv(Rating, RatingDeviation, Volatility, Version)
	local self = {
		Rating = Rating,
		RD = RatingDeviation,
		Vol = Volatility or Glicko2.InitialVolatility,
		Version = Version or 2,
	}

	return setmetatable(self, Glicko2)
end

-- Creates a Glicko2 rating
function Glicko2.g1(Rating, RatingDeviation, Volatility)
	return Glicko2.gv(
		Rating or 1500,
		RatingDeviation or 350,
		Volatility,
		1
	)
end

-- Creates a Glicko1 rating
function Glicko2.g2(Rating, RatingDeviation, Volatility)
	return Glicko2.gv(
		Rating or 0,
		RatingDeviation or 350/c,
		Volatility,
		2
	)
end

function Glicko2:copy()
	return Glicko2.gv(self.Rating, self.RD, self.Vol, self.Version)
end

-- Scales glicko rating to Glicko2
function Glicko2:to2()
	if self.Version == 2 then
		return self:copy()
	end

	local g2 = Glicko2.g2((self.Rating - 1500)/c, self.RD/c, self.Vol)
	
	if self.Score then
		g2.Score = self.Score
	end
	
	return g2
end

-- Scales glicko rating to Glicko1
function Glicko2:to1()
	if self.Version == 1 then
		return self:copy()
	end

	local g2 = Glicko2.g2(self.Rating*c + 1500, self.RD*c, self.Vol)
	
	if self.Score then
		g2.Score = self.Score
	end
	
	return g2
end

function Glicko2.serialize(gv)
	return {
		gv.Rating,
		gv.RD,
		gv.Vol,
		gv.Score
	}
end

function Glicko2.deserialize(gv_s, version)
	local constructor = nil

	-- Finds glicko constructor for specified version
	if version == 1 then
		constructor = Glicko2.g1
	elseif version == 2 then
		constructor = Glicko2.g2
	else
		error("Version must be specified for deserialization", 2)
	end
	
	local gv = constructor(gv_s[1], gv_s[2], gv_s[3])

	-- Inserts a score if there is one
	if gv_s[4] then
		gv = gv:score(gv_s[4])
	end
	
	return gv
end

-- Attaches a score to an opponent
function Glicko2:score(score)
	local new_g2 = self:copy()
	
	--lost: 0, win: 1, tie: 0.5
	new_g2.Score = score or 0
	
	return new_g2
end

-- Function g as described in step 3
local function g(RD)
	return 1/sqrt(1 + 3*RD^2/math.pi^2)
end

-- Function E as described in step 3
local function E(rating, opRating, opRD)
	return 1/(1 + exp(-g(opRD)*(rating - opRating)))
end

-- Constructor for function f described in step 5
local function makebigf(g2, v, delta)
	local a = log(g2.Vol^2)
	
	return function(x)
		local numer = exp(x)*(delta^2 - g2.RD^2 - v - exp(x)) --numerator
		local denom = 2*(g2.RD^2 + v + exp(x))^2 --denominator
		local endTerm = (x - a)/(Glicko2.Tau^2) --final term
		
		return numer/denom - endTerm
	end
end

-- Updates a Glicko rating using the last set of matches
function Glicko2:update(matches)
	local g2 = self
	local originalVersion = g2.Version

	-- convert ratings to glicko2
	if originalVersion == 1 then
		g2 = g2:to2()
	end
	
	for i, match in ipairs(matches) do
		if match.Version == 1 then
			matches[i] = match:to2()
		end
	end

	-- step 3: compute v
	local v = 0
	
	for j, match in ipairs(matches) do
		local EValue = E(g2.Rating, match.Rating, match.RD)

		v = v + g(match.RD)^2*EValue*(1 - EValue)
	end
	
	v = 1/v

	-- step 4: compute delta
	local delta = 0

	for j, match in ipairs(matches) do
		local EValue = E(g2.Rating, match.Rating, match.RD)

		delta = delta + g(match.RD)*(match.Score - EValue)
	end

	delta = delta*v

	-- step 5: find new volatility (iterative process)
	local a = log(g2.Vol^2)

	local bigf = makebigf(g2, v, delta)

	-- step 5.2: find initial A and B values
	local A = a
	local B = 0

	if delta^2 > g2.RD^2 + v then
		B = log(delta^2 - g2.RD^2 - v)
	else
		--iterative process for solving B
		local k = 1

		while bigf(a - k*Glicko2.Tau) < 0 do
			k = k + 1
		end

		B = a - k*Glicko2.Tau
	end

	-- step 5.3: compute values of bigf of A and B
	local fA = bigf(A)
	local fB = bigf(B)

	-- step 5.4: iterates until A and B converge
	while math.abs(B - A) > epsilon do
		local C = A + (A - B)*fA/(fB - fA)
		local fC = bigf(C)

		if fC*fB < 0 then
			A = B
			fA = fB
		else
			fA = fA/2
		end

		B = C
		fB = fC
	end

	-- step 5.5: set new volatility
	local newVol = g2.Vol

	if #matches > 0 then
		newVol = exp(A/2)
	end

	-- step 6: compute new rating and RD
	local newRD = sqrt(g2.RD^2 + newVol^2)
	local newRating = g2.Rating
	
	if #matches > 0 then
		newRD = 1/sqrt(1/newRD^2 + 1/v)
		newRating = 0

		for j, match in ipairs(matches) do
			local EValue = E(g2.Rating, match.Rating, match.RD)
			newRating = newRating + g(match.RD)*(match.Score - EValue)
		end

		newRating = g2.Rating + newRD^2*newRating
	end

	--wrap up results
	local result = Glicko2.g2(newRating, newRD, newVol)

	if originalVersion == 1 then
		result = result:to1()
	end

	return result
end

function Glicko2:deviation(deviations)
	deviations = deviations or 2
	local radius = self.RD*deviations

	return self.Rating - radius, self.Rating + radius
end


function Glicko2:range(padding)
	padding = padding or 0
	local small, big = self:deviation()

	return small - padding, big + padding
end

function Glicko2:percent(confidence)
	confidence = math.clamp(confidence, 0, 1)
	assert(confidence < 1, "Percentage cannot be equal or greater than 1")

	--This is a simple inverse erf approximation, has accuracy of +- 0.02
	return self:deviation(.5877*math.log((1 + confidence)/(1 - confidence)))
end

return Glicko2

Additional resources

Wikipedia Article
Glicko Ratings Homepage
Glicko-2 Paper

57 Likes

I don’t know why anyone has replied to this thread yet as this is the first implementation I have seen of this algorithm on roblox. So this is really cool.

2 Likes

A Small Update!

Added two new functions: :deviation and :percent which allows for more control over matching with other ratings by directly dealing with normal distributions.

Moved Epsilon value to the local scope.

2 Likes

Such underrated resource, I like it. I even added it to my BloxyLibrary website in hopes of getting more attention.

Bit off-topic, but how did you learn to implement math/algorithms in code? I personally never understood how people know how to convert English and math into code like this. One thing for certain that I know is that this kind of work amazes me.