├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── README.md ├── index.js ├── package.json └── test └── tests.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arpad: ELO Rating System for Node.js 2 | 3 | This is an implementation of [ELO](http://en.wikipedia.org/wiki/Elo_rating_system) for Node.js (ELO is named after Arpad Elo, hence the package name). 4 | This module is heavily tested and has many features used in real-world ELO situations. 5 | 6 | ## Installation 7 | 8 | ```bash 9 | $ npm install arpad 10 | ``` 11 | 12 | ## Simple Usage 13 | 14 | This is a fairly simple example showing the most common usage for Arpad: 15 | 16 | ```javascript 17 | const Elo = require('arpad'); 18 | 19 | const elo = new Elo(); 20 | 21 | let alice = 1_600; 22 | let bob = 1_300; 23 | 24 | let new_alice = elo.newRatingIfWon(alice, bob); 25 | console.log("Alice's new rating if she won:", new_alice); // 1,605 26 | 27 | let new_bob = elo.newRatingIfWon(bob, alice); 28 | console.log("Bob's new rating if he won:", new_bob); // 1,327 29 | ``` 30 | 31 | ## Complex Usage 32 | 33 | This is a more complex example, making use of K-factor tables and score values: 34 | 35 | ```javascript 36 | const Elo = require('arpad'); 37 | 38 | const uscf = { 39 | default: 32, 40 | 2100: 24, 41 | 2400: 16 42 | }; 43 | 44 | const min_score = 100; 45 | const max_score = 10_000; 46 | 47 | const elo = new Elo(uscf, min_score, max_score); 48 | 49 | let alice = 2_090; 50 | let bob = 2_700; 51 | 52 | let odds_alice_wins = elo.expectedScore(alice, bob); 53 | console.log("The odds of Alice winning are about:", odds_alice_wins); // 0.029 54 | alice = elo.newRating(odds_alice_wins, 1.0, alice); 55 | console.log("Alice's new rating after she won:", alice); // 2121 56 | 57 | odds_alice_wins = elo.expectedScore(alice, bob); 58 | console.log("The odds of Alice winning again are about:", odds_alice_wins); // 0.034 59 | alice = elo.newRating(odds_alice_wins, 1.0, alice); 60 | console.log("Alice's new rating if she won again:", alice); // 2144 61 | ``` 62 | 63 | ## Running Tests 64 | 65 | If you'd like to contribute be sure to run `npm install` to get the required packages and then make changes. 66 | Afterwards simply run the tests. 67 | If everything passes your Pull Request should be ready. 68 | 69 | ```bash 70 | $ npm test 71 | ``` 72 | 73 | ## License 74 | 75 | MIT 76 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const DEFAULT_K_FACTOR = 32; 4 | 5 | /** 6 | * Instantiates a new ELO instance 7 | * 8 | * @param {Number|Object} k_factor The maximum rating change, defaults to 32 9 | * @param {Number} min The minimum value a calculated rating can be 10 | * @param {Number} max Integer The maximum value a calculated rating can be 11 | */ 12 | module.exports = class ELO { 13 | /** 14 | * This is some magical constant used by the ELO system 15 | * 16 | * @link http://en.wikipedia.org/wiki/Elo_rating_system#Performance_rating 17 | */ 18 | static #PERF = 400; 19 | 20 | static #OUTCOME_LOST = 0; 21 | static #OUTCOME_TIED = 0.5; 22 | static #OUTCOME_WON = 1; 23 | 24 | #minimum = -Infinity; 25 | #maximum = Infinity; 26 | #k_factor = DEFAULT_K_FACTOR; 27 | 28 | constructor(k_factor, min, max) { 29 | if (k_factor) { 30 | this.#k_factor = k_factor; 31 | } 32 | 33 | if (typeof min !== 'undefined') { 34 | this.#minimum = min; 35 | } 36 | 37 | if (typeof max !== 'undefined') { 38 | this.#maximum = max; 39 | } 40 | } 41 | 42 | /** 43 | * Returns the K-factor depending on the provided rating 44 | * 45 | * @arg {Number} rating A players rating, e.g. 1200 46 | * @return {Number} The determined K-factor, e.g. 32 47 | */ 48 | getKFactor(rating) { 49 | let k_factor = null; 50 | 51 | if (typeof this.#k_factor === 'number') { 52 | return this.#k_factor; 53 | } 54 | 55 | if (!rating) { 56 | rating = 0; 57 | } 58 | 59 | if (this.#k_factor.default) { 60 | k_factor = this.#k_factor.default; 61 | } 62 | 63 | Object.keys(this.#k_factor).forEach((minimum_rating) => { 64 | let current_k_factor = this.#k_factor[minimum_rating]; 65 | 66 | if (minimum_rating <= rating) { 67 | k_factor = current_k_factor; 68 | } 69 | }); 70 | 71 | return k_factor; 72 | } 73 | 74 | /** 75 | * Returns the minimum acceptable rating value 76 | * 77 | * @return {Number} The minimum rating value, e.g. 100 78 | */ 79 | getMin() { 80 | return this.#minimum; 81 | } 82 | 83 | /** 84 | * Returns the maximum acceptable rating value 85 | * 86 | * @return {Number} The maximum rating value, e.g. 2700 87 | */ 88 | getMax() { 89 | return this.#maximum; 90 | } 91 | 92 | /** 93 | * When setting the K-factor, you can do one of three things. 94 | * Provide a falsey value, and we'll default to using 32 for everything. 95 | * Provide a number, and we'll use that for everything. 96 | * Provide an object where each key is a numerical lower value. 97 | * 98 | * @arg {Number|Object} k_factor The K-factor to use 99 | * @return {Object} The current object for chaining purposes 100 | */ 101 | setKFactor (k_factor) { 102 | if (k_factor) { 103 | this.#k_factor = k_factor; 104 | } else { 105 | this.#k_factor = DEFAULT_K_FACTOR; 106 | } 107 | 108 | return this; 109 | } 110 | 111 | /** 112 | * Sets the minimum acceptable rating 113 | * 114 | * @arg {Number} minimum The minimum acceptable rating, e.g. 100 115 | * @return {Object} The current object for chaining purposes 116 | */ 117 | setMin(minimum) { 118 | this.#minimum = minimum; 119 | 120 | return this; 121 | } 122 | 123 | /** 124 | * Sets the maximum acceptable rating 125 | * 126 | * @arg {Number} maximum The maximum acceptable rating, e.g. 2700 127 | * @return {Object} The current object for chaining purposes 128 | */ 129 | setMax(maximum) { 130 | this.#maximum = maximum; 131 | 132 | return this; 133 | } 134 | 135 | /** 136 | * Determines the expected "score" of a match 137 | * 138 | * @param {Number} rating The rating of the person whose expected score we're looking for, e.g. 1200 139 | * @param {Number} opponent_rating the rating of the challening person, e.g. 1200 140 | * @return {Number} The score we expect the person to recieve, e.g. 0.5 141 | * 142 | * @link http://en.wikipedia.org/wiki/Elo_rating_system#Mathematical_details 143 | */ 144 | expectedScore(rating, opponent_rating) { 145 | const difference = opponent_rating - rating; 146 | 147 | return 1 / (1 + Math.pow(10, difference/ELO.#PERF)); 148 | } 149 | 150 | /** 151 | * Returns an array of anticipated scores for both players 152 | * 153 | * @param {Number} player_1_rating The rating of player 1, e.g. 1200 154 | * @param {Number} player_2_rating The rating of player 2, e.g. 1200 155 | * @return {Array} The anticipated scores, e.g. [0.25, 0.75] 156 | */ 157 | bothExpectedScores(player_1_rating, player_2_rating) { 158 | return [ 159 | this.expectedScore(player_1_rating, player_2_rating), 160 | this.expectedScore(player_2_rating, player_1_rating) 161 | ]; 162 | } 163 | 164 | /** 165 | * The calculated new rating based on the expected outcone, actual outcome, and previous score 166 | * 167 | * @param {Number} expected_score The expected score, e.g. 0.25 168 | * @param {Number} actual_score The actual score, e.g. 1 169 | * @param {Number} previous_rating The previous rating of the player, e.g. 1200 170 | * @return {Number} The new rating of the player, e.g. 1256 171 | */ 172 | newRating(expected_score, actual_score, previous_rating) { 173 | const difference = actual_score - expected_score; 174 | let rating = Math.round(previous_rating + this.getKFactor(previous_rating) * difference); 175 | 176 | if (rating < this.#minimum) { 177 | rating = this.#minimum; 178 | } else if (rating > this.#maximum) { 179 | rating = this.#maximum; 180 | } 181 | 182 | return rating; 183 | } 184 | 185 | /** 186 | * Calculates a new rating from an existing rating and opponents rating if the player won 187 | * 188 | * This is a convenience method which skips the score concept 189 | * 190 | * @param {Number} rating The existing rating of the player, e.g. 1200 191 | * @param {Number} opponent_rating The rating of the opponent, e.g. 1300 192 | * @return {Number} The new rating of the player, e.g. 1300 193 | */ 194 | newRatingIfWon(rating, opponent_rating) { 195 | const odds = this.expectedScore(rating, opponent_rating); 196 | 197 | return this.newRating(odds, ELO.#OUTCOME_WON, rating); 198 | } 199 | 200 | /** 201 | * Calculates a new rating from an existing rating and opponents rating if the player lost 202 | * 203 | * This is a convenience method which skips the score concept 204 | * 205 | * @param {Number} rating The existing rating of the player, e.g. 1200 206 | * @param {Number} opponent_rating The rating of the opponent, e.g. 1300 207 | * @return {Number} The new rating of the player, e.g. 1180 208 | */ 209 | newRatingIfLost(rating, opponent_rating) { 210 | const odds = this.expectedScore(rating, opponent_rating); 211 | 212 | return this.newRating(odds, ELO.#OUTCOME_LOST, rating); 213 | } 214 | 215 | /** 216 | * Calculates a new rating from an existing rating and opponents rating if the player tied 217 | * 218 | * This is a convenience method which skips the score concept 219 | * 220 | * @param {Number} rating The existing rating of the player, e.g. 1200 221 | * @param {Number} opponent_rating The rating of the opponent, e.g. 1300 222 | * @return {Number} The new rating of the player, e.g. 1190 223 | */ 224 | newRatingIfTied(rating, opponent_rating) { 225 | const odds = this.expectedScore(rating, opponent_rating); 226 | 227 | return this.newRating(odds, ELO.#OUTCOME_TIED, rating); 228 | } 229 | }; 230 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arpad", 3 | "version": "2.0.0", 4 | "description": "An implementation of the ELO Rating System", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node ./test/tests.js" 8 | }, 9 | "keywords": [ 10 | "elo", 11 | "arpad", 12 | "rating", 13 | "scoring", 14 | "matchmaking", 15 | "chess", 16 | "pvp" 17 | ], 18 | "author": "Thomas Hunter II ", 19 | "license": "MIT", 20 | "directories": { 21 | "test": "test" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/tlhunter/node-arpad.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/tlhunter/node-arpad/issues" 29 | }, 30 | "homepage": "https://github.com/tlhunter/node-arpad", 31 | "devDependencies": { 32 | "tape": "^4.6.3" 33 | }, 34 | "engines": { 35 | "node": ">=12.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const test = require('tape'); 6 | 7 | const Elo = require('../index.js'); 8 | 9 | test("sanity check", (t) => { 10 | t.ok(approximate(1, 1, 0)); 11 | t.ok(approximate(1, 1)); 12 | t.ok(approximate(9999.99999999, 9999.999999998)); 13 | t.notOk(approximate(0.12345, 0.12347)); 14 | t.notOk(approximate(0.12347, 0.12345)); 15 | t.ok(approximate(0.12345, 0.12346)); 16 | t.ok(approximate(0.12346, 0.12345)); 17 | t.end(); 18 | }); 19 | 20 | test("should have expected defaults", (t) => { 21 | let myElo = new Elo(); 22 | 23 | t.equal(myElo.getKFactor(), 32); 24 | t.equal(myElo.getMin(), -Infinity); 25 | t.equal(myElo.getMax(), Infinity); 26 | t.end(); 27 | }); 28 | 29 | test("should allow passing in defaults via the constructor", (t) => { 30 | let fide = new Elo(20, 2251, 2825); 31 | 32 | t.equal(fide.getKFactor(), 20); 33 | t.equal(fide.getMin(), 2251); 34 | t.equal(fide.getMax(), 2825); 35 | t.end(); 36 | }); 37 | 38 | test("should allow updating of parameters after instantiation", (t) => { 39 | let uscf = new Elo(31, 0, Infinity); 40 | 41 | t.equal(uscf.getKFactor(), 31); 42 | t.equal(uscf.getMin(), 0); 43 | t.equal(uscf.getMax(), Infinity); 44 | 45 | uscf.setMin(100).setMax(2500).setKFactor(27); 46 | 47 | t.equal(uscf.getMin(), 100); 48 | t.equal(uscf.getMax(), 2500); 49 | t.equal(uscf.getKFactor(), 27); 50 | t.end(); 51 | }); 52 | 53 | test("prevents scores leaving boundaries", (t) => { 54 | let elo = new Elo(32, 100, 2800); 55 | 56 | let alice = 2000; 57 | 58 | for (let i = 0; i < 100; i++) { 59 | alice = elo.newRating(1, 0, alice); 60 | } 61 | 62 | t.equal(alice, 100); 63 | 64 | let bob = 200; 65 | 66 | for (let j = 0; j < 100; j++) { 67 | bob = elo.newRating(0, 1, bob); 68 | } 69 | 70 | t.equal(bob, 2800); 71 | t.end(); 72 | }); 73 | 74 | test("should determine the probability that someone will win or lose", (t) => { 75 | let myElo = new Elo(32, 0, 2500); 76 | 77 | let alice = 1400; 78 | let bob = 1200; 79 | 80 | let odds_alice_wins = myElo.expectedScore(alice, bob); 81 | let odds_bob_wins = myElo.expectedScore(bob, alice); 82 | 83 | t.ok(odds_alice_wins <= 1); 84 | t.ok(odds_alice_wins >= 0); 85 | 86 | t.ok(odds_bob_wins <= 1); 87 | t.ok(odds_bob_wins >= 0); 88 | 89 | t.ok(odds_alice_wins > odds_bob_wins); 90 | t.ok(approximate(odds_alice_wins + odds_bob_wins, 1)); 91 | 92 | let both_odds = myElo.bothExpectedScores(alice, bob); 93 | 94 | t.equal(odds_alice_wins, both_odds[0]); 95 | t.equal(odds_bob_wins, both_odds[1]); 96 | t.end(); 97 | }); 98 | 99 | test("should calculate same scores as WikiPedia", (t) => { 100 | let elo = new Elo(32); 101 | 102 | let rating = 1613; 103 | 104 | let opponents = [ 105 | {rating: 1609, score: 0.506}, 106 | {rating: 1477, score: 0.686}, 107 | {rating: 1388, score: 0.785}, 108 | {rating: 1586, score: 0.539}, 109 | {rating: 1720, score: 0.351} 110 | ]; 111 | 112 | opponents.forEach((pair) => { 113 | let actual = elo.expectedScore(rating, pair.rating); 114 | t.ok(approximate(actual, pair.score, 0.001)); 115 | }); 116 | t.end(); 117 | }); 118 | 119 | test("should calculate same ratings as WikiPedia", (t) => { 120 | let elo = new Elo(32); 121 | 122 | let current_rating = 1613; 123 | let actual_score = 3; 124 | let expected_score = 2.867; 125 | 126 | t.equal(elo.newRating(expected_score, actual_score, current_rating), 1617); 127 | t.end(); 128 | }); 129 | 130 | test("should do some end-to-end examples", (t) => { 131 | let elo = new Elo(24, 200, 3000); 132 | 133 | let alice_rating = 1600; 134 | let bob_rating = 1300; 135 | 136 | let expected_alice_score = elo.expectedScore(alice_rating, bob_rating); 137 | let expected_bob_score = elo.expectedScore(bob_rating, alice_rating); 138 | 139 | t.ok(approximate(expected_alice_score, 0.849, 0.001)); 140 | t.ok(approximate(expected_bob_score, 0.151, 0.001)); 141 | 142 | // Assuming Alice wins (which is expected) 143 | 144 | let alice_new_rating = elo.newRating(expected_alice_score, 1, alice_rating); 145 | let bob_new_rating = elo.newRating(expected_bob_score, 0, bob_rating); 146 | 147 | t.equal(alice_new_rating, 1604); 148 | t.equal(bob_new_rating, 1296); 149 | 150 | // Assuming Bobb wins (which is unexpected) 151 | 152 | alice_new_rating = elo.newRating(expected_alice_score, 0, alice_rating); 153 | bob_new_rating = elo.newRating(expected_bob_score, 1, bob_rating); 154 | 155 | t.equal(alice_new_rating, 1580); 156 | t.equal(bob_new_rating, 1320); 157 | t.end(); 158 | }); 159 | 160 | test("should get same results when using convenience methods", (t) => { 161 | let elo = new Elo(32); 162 | 163 | let alice_rating = 1275; 164 | let bob_rating = 1362; 165 | 166 | let expected_alice_score = elo.expectedScore(alice_rating, bob_rating); 167 | let alice_new_rating = elo.newRating(expected_alice_score, 1, alice_rating); 168 | let alice_new_rating_convenient = elo.newRatingIfWon(alice_rating, bob_rating); 169 | 170 | t.equal(alice_new_rating, alice_new_rating_convenient); 171 | 172 | expected_alice_score = elo.expectedScore(alice_rating, bob_rating); 173 | alice_new_rating = elo.newRating(expected_alice_score, 0, alice_rating); 174 | alice_new_rating_convenient = elo.newRatingIfLost(alice_rating, bob_rating); 175 | 176 | t.equal(alice_new_rating, alice_new_rating_convenient); 177 | 178 | expected_alice_score = elo.expectedScore(alice_rating, bob_rating); 179 | alice_new_rating = elo.newRating(expected_alice_score, 0.5, alice_rating); 180 | alice_new_rating_convenient = elo.newRatingIfTied(alice_rating, bob_rating); 181 | 182 | t.equal(alice_new_rating, alice_new_rating_convenient); 183 | t.end(); 184 | }); 185 | 186 | test("should do valid K-factor lookups with no numeric K-Factor provided", (t) => { 187 | let elo = new Elo(); 188 | 189 | t.equal(elo.getKFactor(), 32); 190 | t.equal(elo.getKFactor(-Infinity), 32); 191 | t.equal(elo.getKFactor(Infinity), 32); 192 | t.equal(elo.getKFactor(0), 32); 193 | t.equal(elo.getKFactor(1), 32); 194 | t.end(); 195 | }); 196 | 197 | test("should do valid K-factor lookups with a numeric K-Factor provided", (t) => { 198 | let elo = new Elo(42); 199 | 200 | t.equal(elo.getKFactor(), 42); 201 | t.equal(elo.getKFactor(-Infinity), 42); 202 | t.equal(elo.getKFactor(Infinity), 42); 203 | t.equal(elo.getKFactor(0), 42); 204 | t.equal(elo.getKFactor(1), 42); 205 | t.end(); 206 | }); 207 | 208 | test("table: should perform valid K-factor lookups", (t) => { 209 | let uscf_table = { 210 | 0: 32, 211 | 2100: 24, 212 | 2400: 16 213 | }; 214 | 215 | let elo = new Elo(uscf_table, 0, Infinity); 216 | 217 | t.equal(elo.getKFactor(), 32); 218 | t.equal(elo.getKFactor(0), 32); 219 | t.equal(elo.getKFactor(null), 32); 220 | t.equal(elo.getKFactor(100), 32); 221 | t.equal(elo.getKFactor(-Infinity), null); // Or should this throw an error? 222 | t.equal(elo.getKFactor(-1), null); // Or should this throw an error? 223 | t.equal(elo.getKFactor(2099), 32); 224 | t.equal(elo.getKFactor(2100), 24); 225 | t.equal(elo.getKFactor(Infinity), 16); 226 | t.end(); 227 | }); 228 | 229 | test("table: should allow default for lower bounds lookups", (t) => { 230 | let table = { 231 | 0: 32, 232 | default: 48 233 | }; 234 | 235 | let elo = new Elo(table); 236 | 237 | t.equal(elo.getKFactor(), 32); 238 | t.equal(elo.getKFactor(0), 32); 239 | t.equal(elo.getKFactor(1), 32); 240 | t.equal(elo.getKFactor(-1), 48); 241 | t.equal(elo.getKFactor(-Infinity), 48); 242 | t.equal(elo.getKFactor(Infinity), 32); 243 | t.end(); 244 | }); 245 | 246 | test("table: should use proper table entries when calculating ratings", (t) => { 247 | let table = { 248 | default: 100, 249 | 0: 50, 250 | 1000: 25 251 | }; 252 | 253 | let elo = new Elo(table, -2000, 2000); 254 | 255 | let alice = 500; 256 | let bob = 2000; 257 | let cathy = -500; 258 | 259 | t.equal(elo.newRatingIfWon(alice, bob), 550); 260 | t.equal(elo.newRatingIfWon(cathy, bob), -400); 261 | 262 | let derik = 950; 263 | 264 | derik = elo.newRatingIfWon(derik, bob); 265 | t.equal(derik, 1000); // +50 266 | derik = elo.newRatingIfWon(derik, bob); 267 | t.equal(derik, 1025); // +25 268 | t.end(); 269 | }); 270 | 271 | /** 272 | * is 0.99999999999999 === 0.99999999999999 ? 273 | */ 274 | function approximate(actual, anticipated, threshold) { 275 | if (!threshold) { 276 | threshold = 0.00001; 277 | } 278 | 279 | let difference = Math.abs(actual - anticipated); 280 | 281 | return difference < threshold; 282 | } 283 | --------------------------------------------------------------------------------