Build a Dynamic ELO Rating System
Build a live ELO rating engine with a REST API and real-time leaderboard.
Introduction
30 Second Summary
Every time you play a competitive game online, something invisible decides whether you climb or fall in the rankings. That something is usually a rating algorithm that weighs your skill against your opponent's and adjusts both scores after every match.
In this project, you will build a complete ELO rating system from scratch. You'll implement the same core algorithm used by chess federations and gaming platforms, wire it up to a REST API, and watch a live leaderboard shift in real time as you submit match results.
What You'll Build
You'll submit a match where the lowest-ranked player upsets the #1 seed, then watch both ratings recalculate and the leaderboard reorder itself instantly in your browser.
By the end of this project, you'll have:
- A working ELO engine that calculates win probabilities and updates both players' ratings after every match (e.g., "Magnus has a 76% expected win rate against Ding").
- A REST API that accepts match results and returns a live leaderboard, so you can submit outcomes and see rankings shift from any client.
- A dynamic K-factor system where brand-new players' ratings move faster than established players, solving the cold start problem that flat rating systems suffer from.
- Secret Mission: Add match history tracking and a rating sparkline so you can see each player's rating journey over time.
Are there any prerequisites?
You should be comfortable with JavaScript and have a basic understanding of API concepts (like GET and POST requests). No prior experience with rating algorithms or databases is needed.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project
To build the ELO rating system, you need two things: a server to handle API requests and a database to store player ratings persistently.
Express handles the server side, and better-sqlite3 gives you a persistent database with zero external services to install or manage. In this step, you'll create the project, install both packages, and set up the file structure you'll build on for the rest of the project.
In this step, get ready to:
- Initialize a Node.js project with Express 5 and better-sqlite3.
- Create the file structure for the ELO engine, database, server, and frontend.
- Verify that SQLite loads correctly.
Create the project and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running this command:
mkdir elo-ranking && cd elo-ranking
- Initialize a new Node.js project by running:
npm init -y
You should see a JSON output in your terminal showing the generated package.json with default values.
- Install Express and better-sqlite3 by running:
npm install express@5.2.1 better-sqlite3@12.11.1
What do these packages do?
- express@5.2.1 is a web framework that handles HTTP requests. It creates the API endpoints you'll use to submit matches and query the leaderboard.
- better-sqlite3@12.11.1 is a synchronous SQLite database library. It stores player ratings and match history in a local file with no external database server required.
You should see output confirming that 2 packages were added successfully.
Seeing install errors?
- If you see a permissions error, try running the command with sudo on macOS or running your terminal as Administrator on Windows.
- If better-sqlite3 fails to build, ensure you have a C++ compiler installed. On macOS, run xcode-select --install. On Windows, install the Visual Studio Build Tools.
Help me fix npm install errors for better-sqlite3
Configure package.json and create the file structure
The npm init -y command created a default package.json with placeholder values. You need to update it with the correct entry point and npm scripts for your project.
- Open the elo-ranking folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the elo-ranking folder, and click Open.
- In the Explorer sidebar, click package.json to open it.
- Replace the entire contents of package.json with the following:
{
"name": "elo-ranking",
"version": "1.0.0",
"description": "Dynamic ELO rating system with live leaderboard",
"main": "server.js",
"scripts": {
"start": "node server.js",
"seed": "node seed.js"
},
"dependencies": {
"express": "5.2.1",
"better-sqlite3": "12.11.1"
}
}
What does this configure?
- "main": "server.js" tells Node.js that server.js is the entry point for this project.
- "start": "node server.js" lets you run the server with npm start later.
- "seed": "node seed.js" creates a shortcut for populating the database with test players.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Make sure you've saved package.json before moving on.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "elo-ranking",
"version": "1.0.0",
"description": "Dynamic ELO rating system with live leaderboard",
"main": "server.js",
"scripts": {
"start": "node server.js",
"seed": "node seed.js"
},
"dependencies": {
"express": "5.2.1",
"better-sqlite3": "12.11.1"
}
}
Now create the empty files your project will use. Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Create the JavaScript files by running:
touch elo.js database.js seed.js server.js
- Create the public directory and frontend file by running:
mkdir public && touch public/index.html
- Confirm all files exist by running:
ls
You should see database.js, elo.js, node_modules, package-lock.json, package.json, public, seed.js, and server.js listed in the output.
Missing files in the output?
- If touch is not recognized on Windows, use New-Item instead: New-Item elo.js, database.js, seed.js, server.js.
- Make sure your terminal is in the elo-ranking directory. Run pwd to check your current location.
Help me create files in my elo-ranking project folder
Verify that SQLite loads correctly
Before you start writing code, confirm that better-sqlite3 can create and query a database. This catches any build issues now rather than mid-project.
- Before you run the next command, what do you expect to see if better-sqlite3 installed correctly?
- Test that SQLite works by running:
node -e "const Database = require('better-sqlite3'); const db = new Database(':memory:'); console.log('SQLite version:', db.pragma('compile_options').length + ' compile options loaded')"
You should see a line like SQLite version: 14 compile options loaded printed without errors. The exact number may vary, but any number confirms that better-sqlite3 is working.
Seeing a module not found error?
- Make sure your terminal is inside the elo-ranking folder (where node_modules lives). Run pwd to confirm.
- If you see a build error mentioning native modules, re-run npm install better-sqlite3@12.11.1 and check for compilation errors in the output.
Help me fix better-sqlite3 module loading errors
Your project structure is ready. Next up, you'll build the ELO engine that powers the entire rating system.
Build the ELO Engine
Your project skeleton is in place with all dependencies installed. Now it's time to build the brain of the system.
The ELO algorithm predicts who should win based on rating difference, then adjusts both players' ratings based on whether the actual outcome matched that prediction. You need to see ratings change with your own eyes before building anything on top of this engine.
In this step, get ready to:
- Implement the ELO expected score and rating update functions.
- Create the database schema with players and matches tables.
- Seed the database with five players at different rating levels.
Implement the ELO algorithm in elo.js
The ELO formula calculates win probability from a rating difference, then adjusts both players' ratings based on whether the actual result matched that prediction.
- Open elo.js in VS Code and paste the following code at the top of the file:
// K-factor constants control how much ratings shift per match
const K_DEFAULT = 32;
const K_NEW_PLAYER = 48;
const NEW_PLAYER_THRESHOLD = 10;
// Returns higher K for new players so their ratings move faster
function getKFactor(matchesPlayed) {
return matchesPlayed < NEW_PLAYER_THRESHOLD ? K_NEW_PLAYER : K_DEFAULT;
}
// Core ELO formula: probability of Player A beating Player B
function expectedScore(ratingA, ratingB) {
return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}
What does this code do?
The constants define K-factor values. K_DEFAULT = 32 controls how much a match shifts ratings for experienced players. A higher K means bigger swings.
The expectedScore function is the core ELO formula. It returns a probability between 0 and 1 representing how likely Player A is to beat Player B, based purely on their rating difference.
- Below the expectedScore function, paste the main rating calculation function and the module export:
// Takes two player objects and outcome, returns updated ratings
function calculateNewRatings(playerA, playerB, outcome) {
const expectedA = expectedScore(playerA.rating, playerB.rating);
const expectedB = 1 - expectedA;
// Convert outcome string to numeric score
const scoreA = outcome === 'win' ? 1 : outcome === 'draw' ? 0.5 : 0;
const scoreB = 1 - scoreA;
// Each player gets their own K-factor based on experience
const kA = getKFactor(playerA.matches_played);
const kB = getKFactor(playerB.matches_played);
const newRatingA = Math.round(playerA.rating + kA * (scoreA - expectedA));
const newRatingB = Math.round(playerB.rating + kB * (scoreB - expectedB));
return {
playerA: { ...playerA, rating: newRatingA, matches_played: playerA.matches_played + 1 },
playerB: { ...playerB, rating: newRatingB, matches_played: playerB.matches_played + 1 },
details: {
expectedA: expectedA.toFixed(3),
expectedB: expectedB.toFixed(3),
kA,
kB,
ratingChangeA: newRatingA - playerA.rating,
ratingChangeB: newRatingB - playerB.rating
}
};
}
module.exports = { expectedScore, calculateNewRatings, getKFactor };
What does calculateNewRatings do?
This function takes two player objects and a match outcome (win, loss, or draw), then returns both players' updated ratings plus details about the calculation.
The formula is: new rating = old rating + K * (actual score - expected score). If a lower-rated player beats a higher-rated one, the surprise factor is large, so the rating shift is bigger. Expected outcomes produce smaller shifts.
- Save elo.js.
- Test that the module works by running this command in your terminal:
node -e "const {expectedScore} = require('./elo'); console.log('Magnus (1700) vs Ding (1500):', (expectedScore(1700, 1500) * 100).toFixed(1) + '% expected win rate')"
You should see Magnus has roughly a 76% expected win rate against Ding. The 200-point rating gap translates directly into that probability.
Seeing a syntax error or module not found?
Make sure you saved elo.js before running the command. Check that both code blocks are in the file, with calculateNewRatings appearing below expectedScore.
If you see Cannot find module './elo', confirm your terminal is in the elo-ranking directory.
Help me debug my ELO algorithm
✔️ Awesome, I've got everything!
Great. Make sure you've saved elo.js and move on to the database setup.
ⓧ I'd like to double check the full code
Your elo.js file should have these sections in order:
- Three constants (K_DEFAULT, K_NEW_PLAYER, NEW_PLAYER_THRESHOLD) at the top.
- The getKFactor function that returns a K value based on matches played.
- The expectedScore function with the ELO probability formula.
- The calculateNewRatings function that takes two players and an outcome, returning updated ratings and details.
- A module.exports line exporting all three functions.
Set up the database schema in database.js
Ratings are meaningless if they vanish when the server stops. SQLite gives you a persistent database in a single file with zero configuration.
- Open database.js and paste the following code to connect to the database and create the tables:
const Database = require('better-sqlite3');
const path = require('path');
// Create or open the database file in the project directory
const db = new Database(path.join(__dirname, 'rankings.db'));
// Players table stores current state for each competitor
db.exec(`
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
rating INTEGER DEFAULT 1500,
matches_played INTEGER DEFAULT 0
)
`);
// Matches table records every result with rating snapshots
db.exec(`
CREATE TABLE IF NOT EXISTS matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_a_id INTEGER NOT NULL,
player_b_id INTEGER NOT NULL,
outcome TEXT NOT NULL,
rating_change_a INTEGER NOT NULL,
rating_change_b INTEGER NOT NULL,
rating_after_a INTEGER NOT NULL,
rating_after_b INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (player_a_id) REFERENCES players(id),
FOREIGN KEY (player_b_id) REFERENCES players(id)
)
`);
What do these tables store?
The players table holds each player's current state: their name, rating (defaulting to 1500), and total matches played.
The matches table records every match result along with the rating changes and post-match ratings for both players. This creates a full history of how ratings evolved over time.
- Below the table creation code, paste the prepared statements and module export:
// Pre-compiled queries for fast, safe database access
const getPlayer = db.prepare('SELECT * FROM players WHERE id = ?');
const getPlayerByName = db.prepare('SELECT * FROM players WHERE name = ?');
const getAllPlayers = db.prepare('SELECT * FROM players ORDER BY rating DESC');
const insertPlayer = db.prepare('INSERT INTO players (name) VALUES (?)');
const updatePlayerRating = db.prepare('UPDATE players SET rating = ?, matches_played = ? WHERE id = ?');
const insertMatch = db.prepare('INSERT INTO matches (player_a_id, player_b_id, outcome, rating_change_a, rating_change_b, rating_after_a, rating_after_b) VALUES (?, ?, ?, ?, ?, ?, ?)');
const getMatchHistory = db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 20');
module.exports = {
db,
getPlayer,
getPlayerByName,
getAllPlayers,
insertPlayer,
updatePlayerRating,
insertMatch,
getMatchHistory
};
Why prepared statements?
Prepared statements are pre-compiled SQL queries. You define the query shape once, then call .run(), .get(), or .all() with parameters later. They're faster than re-parsing SQL on every call and protect against SQL injection.
- Save database.js.
- Verify the database file gets created by running:
node -e "require('./database'); console.log('Database created successfully')"
- Confirm the file now exists by running:
ls rankings.db
You should see rankings.db listed in the output. The database file is now on disk, ready to store player data.
Don't see rankings.db?
If you see an error about better-sqlite3, run npm install again to ensure the native module compiled correctly for your system.
If you see a syntax error, check that the backtick template literals in the db.exec() calls aren't using regular quotes.
Help me debug my database setup
✔️ Awesome, I've got everything!
Great. Make sure you've saved database.js and move on to seeding players.
ⓧ I'd like to double check the full code
Your database.js file should have these sections in order:
- The require statements for better-sqlite3 and path at the top.
- A new Database() call pointing to rankings.db.
- Two db.exec() calls creating the players and matches tables.
- Seven prepared statements (getPlayer, getPlayerByName, getAllPlayers, insertPlayer, updatePlayerRating, insertMatch, getMatchHistory).
- A module.exports object exporting db and all seven prepared statements.
Create seed.js and populate the database
An empty leaderboard isn't very interesting. You need players with pre-existing ratings so you can immediately test what happens when they compete against each other.
- Open seed.js and paste the following code to populate five chess players with established ratings:
const { db, insertPlayer, updatePlayerRating } = require('./database');
// Five players with pre-set ratings simulating an established league
const players = [
{ name: 'Magnus', rating: 1700, matches_played: 25 },
{ name: 'Hikaru', rating: 1650, matches_played: 22 },
{ name: 'Fabiano', rating: 1600, matches_played: 20 },
{ name: 'Ian', rating: 1550, matches_played: 18 },
{ name: 'Ding', rating: 1500, matches_played: 15 }
];
// Transaction ensures all inserts succeed or fail together
const seedAll = db.transaction(() => {
for (const player of players) {
try {
const info = insertPlayer.run(player.name);
updatePlayerRating.run(player.rating, player.matches_played, info.lastInsertRowid);
console.log(`Seeded: ${player.name} (Rating: ${player.rating}, Matches: ${player.matches_played})`);
} catch (err) {
if (err.message.includes('UNIQUE')) {
console.log(`Skipped: ${player.name} (already exists)`);
} else {
throw err;
}
}
}
});
seedAll();
console.log('\nDone! Players seeded successfully.');
What does this script do?
The script inserts five players with pre-set ratings ranging from 1500 to 1700. Each player also has 15-25 matches recorded, simulating established competitors whose ratings have already stabilized.
The db.transaction() wrapper ensures all five inserts succeed together or fail together. The UNIQUE constraint catch means you can safely re-run this script without creating duplicate players.
- Save seed.js.
- Run the seed script to populate your database:
node seed.js
You should see each player printed as they're seeded, followed by "Done! Players seeded successfully." at the end.
Before you run the next command, predict: what order will the players appear in the table output? Think about what ORDER BY rating DESC does.
- Verify the data is stored correctly by running:
node -e "const {getAllPlayers} = require('./database'); console.table(getAllPlayers.all())"
You should see a table with all five players sorted by rating from highest (Magnus at 1700) to lowest (Ding at 1500). Each row shows the player's id, name, rating, and matches_played.
Seeing an empty table or an error?
If the table is empty, node seed.js may not have run successfully. Check the output for error messages.
If you see "UNIQUE constraint failed", the players already exist. That's fine. The script skips duplicates. Your table should still show all five players.
Help me debug my seed script
✔️ Awesome, I've got everything!
Great. Double check you've saved seed.js and that your table shows all five players.
ⓧ I'd like to double check the full code
Here's the complete seed.js file:
const { db, insertPlayer, updatePlayerRating } = require('./database');
const players = [
{ name: 'Magnus', rating: 1700, matches_played: 25 },
{ name: 'Hikaru', rating: 1650, matches_played: 22 },
{ name: 'Fabiano', rating: 1600, matches_played: 20 },
{ name: 'Ian', rating: 1550, matches_played: 18 },
{ name: 'Ding', rating: 1500, matches_played: 15 }
];
const seedAll = db.transaction(() => {
for (const player of players) {
try {
const info = insertPlayer.run(player.name);
updatePlayerRating.run(player.rating, player.matches_played, info.lastInsertRowid);
console.log(`Seeded: ${player.name} (Rating: ${player.rating}, Matches: ${player.matches_played})`);
} catch (err) {
if (err.message.includes('UNIQUE')) {
console.log(`Skipped: ${player.name} (already exists)`);
} else {
throw err;
}
}
}
});
seedAll();
console.log('\nDone! Players seeded successfully.');
Your ELO engine is live and your database has five ranked players ready to compete. Next up, you'll build the API that lets you submit match results and query the leaderboard from any client.
Create the Match API
Your ELO engine calculates rating changes and your database stores players. Right now, though, the only way to use them is by writing Node.js scripts.
In this step, you will build a REST API with Express that accepts match results, recalculates ratings, and returns a live leaderboard. Any client (a browser, a curl command, a mobile app) will be able to talk to your rating system.
In this step, get ready to:
- Set up an Express server that serves a leaderboard endpoint.
- Add player creation and match submission endpoints with input validation.
- Test the API by submitting a match and watching ratings change.
Set up Express and the leaderboard endpoint
Express needs a few lines of setup before it can handle requests. You will import your database and ELO modules, configure middleware, and create the first read endpoint.
- Open server.js in VS Code and add the following code:
const express = require('express');
const path = require('path');
const { getAllPlayers, getPlayer, insertPlayer, updatePlayerRating, insertMatch, getMatchHistory } = require('./database');
const { calculateNewRatings } = require('./elo');
const app = express();
const PORT = 3000;
// Parse JSON request bodies and serve frontend files
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Return all players sorted by rating (highest first)
app.get('/api/leaderboard', (req, res) => {
const players = getAllPlayers.all();
res.json(players);
});
app.listen(PORT, () => {
console.log(`ELO Rating System running at http://localhost:${PORT}`);
});
What does this code do?
The imports pull in every prepared statement and the ELO calculation function you built in the previous step.
- express.json() parses incoming JSON request bodies so you can read req.body on POST routes.
- express.static() serves any file inside public/ at the root URL. The frontend you build later will live here.
- GET /api/leaderboard queries all players ordered by rating and returns them as JSON.
- Save server.js.
- Start the server by running this command:
node server.js
You should see ELO Rating System running at http://localhost:3000 printed in your terminal.
- Open a second terminal tab and test the leaderboard by running:
curl http://localhost:3000/api/leaderboard
You should see a JSON array with your 5 seeded players, sorted from Magnus (1700) down to Ding (1500).
Seeing 'Cannot find module' or 'address already in use'?
- If you see a module error, make sure you ran npm install in the previous step and that node_modules/ exists in your project folder.
- If port 3000 is already in use, stop whatever process is using it (check other terminal tabs) and try again.
- help me debug why my Express server won't start
Add the player creation endpoint
The leaderboard shows existing players, but you also need a way to register new ones through the API. This endpoint creates a player at the default 1500 rating.
- Stop the running server by pressing Ctrl+C in the terminal where it is running.
- In server.js, find the app.listen block at the bottom. Add the following code above it:
// Register a new player with default 1500 rating
app.post('/api/players', (req, res) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Player name is required' });
}
try {
const info = insertPlayer.run(name);
const player = getPlayer.get(info.lastInsertRowid);
res.status(201).json(player);
} catch (err) {
if (err.message.includes('UNIQUE')) {
return res.status(409).json({ error: 'Player already exists' });
}
res.status(500).json({ error: 'Failed to create player' });
}
});
What does this code do?
- It destructures name from the request body and rejects the request if it is missing.
- insertPlayer.run(name) inserts the row and returns the auto-generated ID via lastInsertRowid.
- If the name already exists, SQLite throws a UNIQUE constraint error. The catch block turns that into a friendly 409 response.
- Save server.js and restart the server:
node server.js
- In your second terminal, create a test player by running:
curl -X POST http://localhost:3000/api/players -H "Content-Type: application/json" -d '{"name":"TestPlayer"}'
You should see a JSON object with id, name: "TestPlayer", rating: 1500, and matches_played: 0.
Getting a 400 or empty response?
- Make sure you included the -H "Content-Type: application/json" header. Without it, Express cannot parse the JSON body.
- Check that app.use(express.json()) appears before your routes in server.js.
- help me fix my POST /api/players endpoint
Build the match submission endpoint
This is the core of the API. It accepts two player IDs and an outcome, validates the input, runs the ELO calculation, updates both players in the database, and returns the rating changes.
- Stop the server with Ctrl+C.
- In server.js, add the following code above app.listen (below the POST /api/players route). This opens the match handler and validates all inputs:
// Process a match result and update both players' ratings
app.post('/api/matches', (req, res) => {
const { playerAId, playerBId, outcome } = req.body;
// Validate required fields
if (!playerAId || !playerBId || !outcome) {
return res.status(400).json({ error: 'playerAId, playerBId, and outcome are required' });
}
// Only allow valid outcomes
if (!['win', 'loss', 'draw'].includes(outcome)) {
return res.status(400).json({ error: 'outcome must be win, loss, or draw' });
}
// Prevent self-matches
if (playerAId === playerBId) {
return res.status(400).json({ error: 'A player cannot play against themselves' });
}
// Look up both players
const playerA = getPlayer.get(playerAId);
const playerB = getPlayer.get(playerBId);
if (!playerA || !playerB) {
return res.status(404).json({ error: 'One or both players not found' });
}
Why validate so carefully?
Each check prevents a specific problem. Missing fields would crash the ELO calculation. An invalid outcome would produce nonsense scores. A self-match would waste a database write. Checking player existence prevents foreign key errors in the matches table.
- Directly below the code you just added (still above app.listen), add the ELO calculation, database updates, and the match history endpoint to complete the file:
// Run ELO calculation and persist results
const result = calculateNewRatings(playerA, playerB, outcome);
updatePlayerRating.run(result.playerA.rating, result.playerA.matches_played, playerA.id);
updatePlayerRating.run(result.playerB.rating, result.playerB.matches_played, playerB.id);
insertMatch.run(
playerA.id, playerB.id, outcome,
result.details.ratingChangeA, result.details.ratingChangeB,
result.playerA.rating, result.playerB.rating
);
res.json({
playerA: { id: playerA.id, name: playerA.name, newRating: result.playerA.rating, change: result.details.ratingChangeA },
playerB: { id: playerB.id, name: playerB.name, newRating: result.playerB.rating, change: result.details.ratingChangeB },
details: result.details
});
});
// Return the 20 most recent matches
app.get('/api/matches', (req, res) => {
const matches = getMatchHistory.all();
res.json(matches);
});
What does the match logic do?
- calculateNewRatings returns updated player objects and detailed stats (expected scores, K-factors, rating changes).
- Both players' ratings and match counts are updated in the database immediately.
- The match record is inserted with the rating changes and final ratings for historical tracking.
- The response includes both players' new ratings, the change amount, and the expected probabilities so the caller can display meaningful results.
- Save server.js and restart the server:
node server.js
Before you test, think about what should happen. Ding is rated 1500 and Magnus is rated 1700. If Ding beats Magnus, who gains more points? What would the ELO formula predict?
- In your second terminal, submit an upset match where Ding (player 5) beats Magnus (player 1) by running:
curl -X POST http://localhost:3000/api/matches -H "Content-Type: application/json" -d '{"playerAId":5,"playerBId":1,"outcome":"win"}'
You should see Ding gain approximately 24 points and Magnus lose approximately 24 points. The expected score shows Magnus was heavily favoured, but the upset still happened. Both players used K=32 because they have 10+ matches played.
Why is the gain and loss equal?
With a flat K-factor of 32 for both players, the ELO system is perfectly zero-sum. The points one player gains are exactly what the other loses. This changes in Step 5 when you introduce dynamic K-factors.
Seeing 'One or both players not found'?
- Player IDs come from the seed script. Run curl http://localhost:3000/api/leaderboard to see the actual IDs. They should be 1 through 5.
- If your database got into a bad state, delete rankings.db and re-run node seed.js to start fresh.
- help me debug my match submission endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and that your server is running without errors.
ⓧ I'd like to double check the full code
Your server.js should contain these sections in order. Scroll through your file and confirm each is present:
- Imports: express, path, database functions, and calculateNewRatings.
- App setup: express(), PORT = 3000, express.json(), express.static().
- GET /api/leaderboard returning all players sorted by rating.
- POST /api/players with name validation and UNIQUE error handling.
- POST /api/matches with four validation checks, ELO calculation, database updates, and match record insertion.
- GET /api/matches returning the 20 most recent matches.
- app.listen on port 3000 with a console log.
Your API is live and processing matches. Next up, you will build a visual leaderboard that lets you submit matches from the browser and watch rankings shift in real time.
Build the Live Leaderboard UI
Your API works, but staring at JSON in a terminal doesn't let you feel the ratings shift.
In this step, you'll build a visual leaderboard that reorders itself after each match submission. Watching a player climb or drop after an upset makes the ELO algorithm tangible in a way raw numbers never can.
In this step, get ready to:
- Build a leaderboard panel that fetches and displays all players ranked by rating.
- Add a match submission form with rating change display and expected win probability.
- Add a New Player form and observe how the K-factor system treats newcomers differently.
Build the leaderboard panel and match submission form
The public/index.html file is currently empty. You'll fill it with a two-panel layout: a leaderboard on the left and a match submission form on the right.
- Open public/index.html in VS Code and paste the document head with all the styles:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ELO Rating System</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 2rem; }
h1 { text-align: center; margin-bottom: 2rem; color: #00d4ff; }
.container { max-width: 900px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
.panel { background: #16213e; border-radius: 12px; padding: 1.5rem; }
.panel h2 { margin-bottom: 1rem; color: #00d4ff; font-size: 1.1rem; }
.leaderboard { list-style: none; }
.leaderboard li { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; border-bottom: 1px solid #0f3460; }
.leaderboard li:last-child { border-bottom: none; }
.rank { font-weight: bold; color: #888; width: 2rem; }
.name { flex: 1; margin-left: 0.5rem; }
.rating { font-weight: bold; color: #00d4ff; }
.matches-count { font-size: 0.8rem; color: #888; margin-left: 0.5rem; }
.k-badge { font-size: 0.7rem; background: #e94560; color: white; padding: 2px 6px; border-radius: 4px; margin-left: 0.5rem; }
select, button, input { padding: 0.5rem; border-radius: 6px; border: 1px solid #0f3460; background: #0f3460; color: #eee; font-size: 0.9rem; width: 100%; margin-bottom: 0.75rem; }
button { background: #00d4ff; color: #1a1a2e; font-weight: bold; cursor: pointer; border: none; }
button:hover { background: #00b8d4; }
.result { margin-top: 1rem; padding: 1rem; background: #0f3460; border-radius: 8px; font-size: 0.85rem; line-height: 1.6; }
.change-positive { color: #4caf50; font-weight: bold; }
.change-negative { color: #e94560; font-weight: bold; }
.new-player-form { display: flex; gap: 0.5rem; }
.new-player-form input { margin-bottom: 0; }
.new-player-form button { width: auto; padding: 0.5rem 1rem; }
.full-width { grid-column: 1 / -1; }
label { display: block; font-size: 0.8rem; color: #888; margin-bottom: 0.25rem; }
</style>
</head>
What does this style block do?
The CSS creates a dark-themed two-column grid layout. The left column holds the leaderboard. The right column holds the match form.
The .change-positive and .change-negative classes will color rating gains green and losses red, making shifts instantly visible.
- Add the body HTML structure directly below the closing </head> tag:
<body>
<h1>ELO Rating System</h1>
<div class="container">
<div class="panel">
<h2>Leaderboard</h2>
<ul class="leaderboard" id="leaderboard"></ul>
</div>
<div class="panel">
<h2>Submit Match Result</h2>
<label>Player A</label>
<select id="playerA"></select>
<label>Player B</label>
<select id="playerB"></select>
<label>Outcome (from Player A's perspective)</label>
<select id="outcome">
<option value="win">Player A Wins</option>
<option value="loss">Player A Loses</option>
<option value="draw">Draw</option>
</select>
<button onclick="submitMatch()">Submit Match</button>
<div class="result" id="result" style="display:none;"></div>
</div>
<div class="panel full-width">
<h2>Add New Player</h2>
<div class="new-player-form">
<input type="text" id="newPlayerName" placeholder="Enter player name">
<button onclick="addPlayer()">Add</button>
</div>
</div>
</div>
What does this HTML structure do?
- The #leaderboard list will be populated dynamically by JavaScript with player rankings.
- The two <select> dropdowns for Player A and Player B will also be populated with player names and ratings from the API.
- The #result div is hidden by default. It becomes visible after a match is submitted to show the rating changes.
Now you need the JavaScript that fetches player data from your API and renders it. The loadLeaderboard function handles both the ranked list and the player dropdowns.
- Add a <script> block below the closing </div> of the container. Paste the loadLeaderboard function:
<script>
async function loadLeaderboard() {
const res = await fetch('/api/leaderboard');
const players = await res.json();
const list = document.getElementById('leaderboard');
// Build each leaderboard row with rank, name, match count, and rating
list.innerHTML = players.map((p, i) => `
<li>
<span class="rank">#${i + 1}</span>
<span class="name">${p.name}</span>
${p.matches_played < 10 ? '<span class="k-badge">NEW K=48</span>' : ''}
<span class="matches-count">(${p.matches_played} games)</span>
<span class="rating">${p.rating}</span>
</li>
`).join('');
// Populate both player dropdowns with current names and ratings
const selects = ['playerA', 'playerB'];
selects.forEach(id => {
const select = document.getElementById(id);
select.innerHTML = players.map(p =>
`<option value="${p.id}">${p.name} (${p.rating})</option>`
).join('');
});
}
What does loadLeaderboard do?
- It calls GET /api/leaderboard and renders each player as a list item with their rank position, name, games played, and current rating.
- Players with fewer than 10 matches get a red "NEW K=48" badge indicating their ratings move faster.
- It also populates both Player A and Player B dropdowns so you can select matchups.
The leaderboard won't load yet because the file isn't complete. You need to add the remaining functions and close the HTML tags before the browser can render it.
Add match result display
The submitMatch function sends the selected players and outcome to your API, then displays how each rating changed with color-coded results.
- Add the submitMatch function below loadLeaderboard. First, the request and validation logic:
async function submitMatch() {
const playerAId = parseInt(document.getElementById('playerA').value);
const playerBId = parseInt(document.getElementById('playerB').value);
const outcome = document.getElementById('outcome').value;
if (playerAId === playerBId) {
alert('Please select two different players');
return;
}
const res = await fetch('/api/matches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerAId, playerBId, outcome })
});
const data = await res.json();
if (!res.ok) {
alert(data.error);
return;
}
What does this code do?
- It reads the selected player IDs and outcome from the form dropdowns.
- The client-side check prevents submitting a match where a player faces themselves (the API also validates this, but catching it early avoids an unnecessary network request).
- It POSTs the match to /api/matches and alerts any server-side error.
- Continue the submitMatch function by adding the result display logic directly below the code above:
// Show the rating changes with color coding
const resultDiv = document.getElementById('result');
resultDiv.style.display = 'block';
const changeA = data.details.ratingChangeA;
const changeB = data.details.ratingChangeB;
resultDiv.innerHTML = `
<strong>${data.playerA.name}:</strong> ${data.playerA.newRating}
<span class="${changeA >= 0 ? 'change-positive' : 'change-negative'}">
(${changeA >= 0 ? '+' : ''}${changeA})
</span> K=${data.details.kA}<br>
<strong>${data.playerB.name}:</strong> ${data.playerB.newRating}
<span class="${changeB >= 0 ? 'change-positive' : 'change-negative'}">
(${changeB >= 0 ? '+' : ''}${changeB})
</span> K=${data.details.kB}<br>
<small>Expected: ${data.playerA.name} ${(data.details.expectedA * 100).toFixed(1)}% vs ${data.playerB.name} ${(data.details.expectedB * 100).toFixed(1)}%</small>
`;
// Refresh the leaderboard to show new rankings
loadLeaderboard();
}
What does the result display show?
- Each player's new rating with a green +N for gains or red -N for losses.
- The K-factor used for each player (K=32 for established players, K=48 for newcomers).
- The expected win probability that was calculated before the match (e.g., "Magnus 76.0% vs Ding 24.0%").
- After displaying the result, loadLeaderboard() refreshes the rankings so you see the leaderboard reorder instantly.
Add the New Player form and discover the K-factor system
The New Player form HTML is already in your body structure. You just need the addPlayer function to wire it up, plus the initial call that loads the leaderboard when the page opens.
- Add the addPlayer function, the initialization call, and the closing tags below submitMatch:
async function addPlayer() {
const input = document.getElementById('newPlayerName');
const name = input.value.trim();
if (!name) return;
const res = await fetch('/api/players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (res.ok) {
input.value = '';
loadLeaderboard();
} else {
const err = await res.json();
alert(err.error);
}
}
loadLeaderboard();
</script>
</body>
</html>
What does this code do?
- addPlayer reads the name input, POSTs to /api/players, clears the field on success, and refreshes the leaderboard.
- The standalone loadLeaderboard() call at the bottom runs as soon as the page loads, populating the rankings immediately.
- Save public/index.html.
- Open http://localhost:3000 in your browser.
You should see a dark-themed page with all five seeded players ranked by rating on the left (Magnus at #1 with 1700) and the match submission form on the right.
Seeing a blank page?
- Open DevTools with **Cmd+Option+I** (macOS) or **Ctrl+Shift+I** (Windows) and check the Console tab for JavaScript errors.
- Confirm your server is still running in the terminal. If it crashed, restart it with node server.js.
- Check that the closing </script>, </body>, and </html> tags are all present at the end of your file.
Why isn't my leaderboard loading in the browser?
Now test the match submission. Before you do, think about what you expect to happen when the #5 ranked player beats #1.
- Select **Ding** as Player A and **Magnus** as Player B.
- Keep the outcome as **Player A Wins**.
- Click **Submit Match**.
You should see the result panel appear below the button showing both players' new ratings. Ding gains points (green) and Magnus loses points (red). The expected win probability shows how unlikely the upset was. The leaderboard on the left reorders to reflect the new rankings.
Now test the New Player form to see how the system treats a newcomer.
- Type Rookie in the **Add New Player** input and click **Add**.
Rookie appears at the bottom of the leaderboard with a rating of 1500 and a red "NEW K=48" badge. This badge indicates that Rookie's K-factor is higher than established players.
- Select **Rookie** as Player A and **Magnus** as Player B.
- Keep the outcome as **Player A Wins** (Rookie upsets the top player).
- Click **Submit Match**.
Look at the result. Rookie gains about 36 points (K=48) while Magnus loses about 24 points (K=32). The K-factor asymmetry is already visible. A newcomer's rating moves faster because their K-factor is higher.
Why does the K-factor matter here?
With K=48, Rookie's rating adjusts 50% faster per match than Magnus's K=32. This means fewer matches are needed for Rookie to reach their true skill level.
Once Rookie passes 10 matches, they'll drop to K=32 and their rating will stabilize. This is the same principle FIDE uses for chess ratings. New players get K=40, established players get K=20.
✔️ Awesome, I've got everything!
Great. Double check you've saved public/index.html and your leaderboard is reordering after match submissions.
ⓧ I'd like to double check the full code
Your public/index.html is built from multiple chunks. Scroll up and confirm each one is present in order:
- The document head with all CSS styles (from <!DOCTYPE html> through </head>).
- The body HTML structure with leaderboard panel, match form, and new player form.
- The loadLeaderboard function inside a <script> tag.
- The submitMatch function (request logic + result display).
- The addPlayer function, the loadLeaderboard() init call, and closing </script></body></html> tags.
Your live leaderboard is working. You can submit matches, see ratings shift with color-coded changes, and watch players climb or drop in real time. In the next step, you'll dig deeper into how the dynamic K-factor system calibrates itself and explore what happens as new players accumulate matches.
Fix the Cold Start Problem with Dynamic K-Factor
You saw the problem at the end of the last step. Rookie pulled off a massive upset against Magnus (the #1 player) but only gained ~24 points. That is the same amount any established player would gain.
Real rating systems solve this with a dynamic K-factor. FIDE chess uses K=40 for new players and K=20 for established ones. The idea: new players' ratings should be more volatile so they converge to their true skill level faster. In this step, you will wire up that same principle in your engine.
In this step, get ready to:
- Update the ELO engine to use per-player K-factors (K=48 for new players, K=32 for established ones).
- Add visual indicators in the UI showing which players have the boosted K-factor.
- Test the fix by replaying the same upset and seeing Rookie gain significantly more points.
Update elo.js to use dynamic K-factor
Your elo.js file already has a getKFactor function defined at the top. It returns K=48 for players with fewer than 10 matches and K=32 for established players. But calculateNewRatings is not using it yet.
- In elo.js, find these two lines inside the calculateNewRatings function:
const kA = K_DEFAULT;
const kB = K_DEFAULT;
- Replace both lines so each player gets their own K-factor based on how many matches they have played:
const kA = getKFactor(playerA.matches_played);
const kB = getKFactor(playerB.matches_played);
- Save elo.js.
What does this change?
Previously, both players in a match had K=32 regardless of experience. Now a brand-new player (fewer than 10 matches) gets K=48, meaning their rating moves 50% faster per match. An established player still gets K=32, keeping their rating stable.
This asymmetry means the system is no longer perfectly zero-sum. A new player can gain more points than the established player loses. That is an intentional tradeoff every real-world rating system makes to help newcomers converge faster.
Why K=48 and K=32?
FIDE uses K=40 for new players (fewer than 30 games) and K=20 for established players. Those values are calibrated for thousands of players rated over years. For a small demo pool, K=48 and K=32 produce visible differences quickly while remaining mathematically sound.
Update the UI to show K-factor indicators
The algorithm now treats new and established players differently. Your UI should make this visible so you can tell at a glance which players have the boosted K-factor.
- In public/index.html, find the leaderboard template inside the loadLeaderboard function. Locate this line:
<span class="name">${p.name}</span>
- Add this line directly below it to show a "NEW" badge for players with fewer than 10 matches:
${p.matches_played < 10 ? '<span class="k-badge">NEW K=48</span>' : ''}
- Save public/index.html.
What does this badge do?
The ternary expression checks p.matches_played < 10. If the player has fewer than 10 matches, it renders a red badge showing "NEW K=48". Otherwise it renders nothing. The .k-badge CSS class is already defined in your stylesheet from when you built the UI.
Next, update the match result display so it shows which K-factor was used for each player.
- In the same file, find the submitMatch function. Locate the resultDiv.innerHTML assignment and replace it with:
resultDiv.innerHTML = `
<strong>${data.playerA.name}:</strong> ${data.playerA.newRating}
<span class="${changeA >= 0 ? 'change-positive' : 'change-negative'}">
(${changeA >= 0 ? '+' : ''}${changeA})
</span> K=${data.details.kA}<br>
<strong>${data.playerB.name}:</strong> ${data.playerB.newRating}
<span class="${changeB >= 0 ? 'change-positive' : 'change-negative'}">
(${changeB >= 0 ? '+' : ''}${changeB})
</span> K=${data.details.kB}<br>
<small>Expected: ${data.playerA.name} ${(data.details.expectedA * 100).toFixed(1)}% vs ${data.playerB.name} ${(data.details.expectedB * 100).toFixed(1)}%</small>
`;
- Save public/index.html.
What does this show?
After each match submission, the result panel now displays K=48 or K=32 next to each player's rating change. This makes the asymmetry immediately visible when a new player faces an established one.
Badge not appearing?
Make sure the template literal line is placed between the <span class="name"> line and the <span class="matches-count"> line. Also confirm the .k-badge class exists in the <style> section of your HTML.
help me debug why my NEW badge is not showing up in the leaderboard
Reset and test the dynamic K-factor
Your database still has Rookie from the last step with match history from the flat-K era. To see a clean comparison, you need to reset everything and replay the upset.
- Stop the running server by pressing Ctrl+C in the terminal.
- Delete the database and re-seed by running these commands:
rm rankings.db
node seed.js
You should see all five players seeded with their original ratings.
- Restart the server by running:
node server.js
- Open http://localhost:3000 in your browser.
- Notice that none of the seeded players have the "NEW" badge. They all have 15+ matches, so they get K=32.
- Type Rookie in the **Add New Player** name field and click **Add**.
Rookie appears at the bottom of the leaderboard at 1500 with a red "NEW K=48" badge. That badge tells you their rating will move faster.
Before you submit the match, think about this: if Rookie (1500, K=48) beats Magnus (1700, K=32), do you expect both players' ratings to change by the same amount? Why or why not?
- Select Rookie as Player A and Magnus as Player B.
- Set the outcome to **Player A Wins**.
- Click **Submit Match**.
The result panel shows the asymmetry. Rookie gains approximately +36 points (K=48) while Magnus loses approximately -24 points (K=32). The match result also displays the K-factor used for each player, confirming the dynamic calculation.
Why the numbers are different
With flat K=32, both players would have changed by ~24 points. Now Rookie's higher K-factor (48) amplifies their gain by 50%. Magnus's K=32 keeps his loss at the normal rate. This means Rookie converges toward their true rating faster without destabilizing the established players.
- Submit a few more matches for Rookie (wins and losses) and watch their rating swing dramatically compared to established players.
- After Rookie reaches 10 matches, refresh the page. The "NEW" badge disappears and their K-factor drops to 32.
Rookie still gaining only ~24 points?
This means calculateNewRatings is still using K_DEFAULT instead of getKFactor. Double check that you replaced both const kA = K_DEFAULT and const kB = K_DEFAULT lines in elo.js. Also confirm you restarted the server after saving your changes.
help me figure out why my dynamic K-factor is not being applied to new players
✔️ Awesome, I've got everything!
Great. Double check you have saved both elo.js and public/index.html.
ⓧ I'd like to double check the full code
Here is the complete elo.js file. Confirm yours matches:
const K_DEFAULT = 32;
const K_NEW_PLAYER = 48;
const NEW_PLAYER_THRESHOLD = 10;
function getKFactor(matchesPlayed) {
return matchesPlayed < NEW_PLAYER_THRESHOLD ? K_NEW_PLAYER : K_DEFAULT;
}
function expectedScore(ratingA, ratingB) {
return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}
function calculateNewRatings(playerA, playerB, outcome) {
const expectedA = expectedScore(playerA.rating, playerB.rating);
const expectedB = 1 - expectedA;
const scoreA = outcome === 'win' ? 1 : outcome === 'draw' ? 0.5 : 0;
const scoreB = 1 - scoreA;
const kA = getKFactor(playerA.matches_played);
const kB = getKFactor(playerB.matches_played);
const newRatingA = Math.round(playerA.rating + kA * (scoreA - expectedA));
const newRatingB = Math.round(playerB.rating + kB * (scoreB - expectedB));
And the return statement:
return {
playerA: { ...playerA, rating: newRatingA, matches_played: playerA.matches_played + 1 },
playerB: { ...playerB, rating: newRatingB, matches_played: playerB.matches_played + 1 },
details: {
expectedA: expectedA.toFixed(3),
expectedB: expectedB.toFixed(3),
kA,
kB,
ratingChangeA: newRatingA - playerA.rating,
ratingChangeB: newRatingB - playerB.rating
}
};
}
module.exports = { expectedScore, calculateNewRatings, getKFactor };
For public/index.html, confirm the leaderboard template inside loadLeaderboard includes the badge line:
list.innerHTML = players.map((p, i) => `
<li>
<span class="rank">#${i + 1}</span>
<span class="name">${p.name}</span>
${p.matches_played < 10 ? '<span class="k-badge">NEW K=48</span>' : ''}
<span class="matches-count">(${p.matches_played} games)</span>
<span class="rating">${p.rating}</span>
</li>
`).join('');
And confirm the result display in submitMatch shows K-factor values:
resultDiv.innerHTML = `
<strong>${data.playerA.name}:</strong> ${data.playerA.newRating}
<span class="${changeA >= 0 ? 'change-positive' : 'change-negative'}">
(${changeA >= 0 ? '+' : ''}${changeA})
</span> K=${data.details.kA}<br>
<strong>${data.playerB.name}:</strong> ${data.playerB.newRating}
<span class="${changeB >= 0 ? 'change-positive' : 'change-negative'}">
(${changeB >= 0 ? '+' : ''}${changeB})
</span> K=${data.details.kB}<br>
<small>Expected: ${data.playerA.name} ${(data.details.expectedA * 100).toFixed(1)}% vs ${data.playerB.name} ${(data.details.expectedB * 100).toFixed(1)}%</small>
`;
Secret mission
Add Rating History and Sparklines
Right now you can see where every player stands, but not how they got there. In this extension, you'll add per-player rating history tracking and render each player's journey as an SVG sparkline. Click any name on the leaderboard to see their peak, their valley, and the streak they're riding.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them entirely.
Resources you used:
- The elo-ranking project directory on your Desktop (source files, node_modules, and the rankings.db SQLite database).
Keep everything running
No action needed. Choose this if you want to keep experimenting with the rating system.
- Your rankings.db file persists between server restarts, so all your player data and match history stays intact.
- Start the server again any time by running node server.js from inside the elo-ranking directory.
Pause - I'll come back to this later
Stop the running server to free up the terminal. Your files and database remain on disk for next time.
- In the terminal where the server is running, press Ctrl+C to stop the process.
- Everything stays in your elo-ranking folder. When you return, run node server.js to pick up where you left off.
Delete - I don't want to use this again
Remove all project files. There are no external resources or cloud services to clean up.
- In the terminal where the server is running, press Ctrl+C to stop the process.
- Navigate to your Desktop and delete the project folder by running these commands:
cd ~/Desktop
rm -rf elo-ranking
On Windows, use this instead:
cd ~\Desktop
rmdir /s /q elo-ranking
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see elo-ranking in the output.
Nice Work!
Nice Work!
You just built a fully functional ELO rating system from scratch. Players start at 1500, ratings shift dynamically after every match, and your leaderboard reorders itself in real time to reflect the new standings.
You've learned how to:
- Implement the ELO algorithm from scratch, calculating expected scores and updating ratings based on match outcomes.
- Build a REST API with Express 5 that processes match results, validates input, and serves a live leaderboard backed by SQLite persistence.
- Identify and fix the cold start problem with a dynamic K-factor (K=48 for new players, K=32 for established ones) so newcomers converge to their true rating faster.
- Secret Mission: Added per-player rating history with an SVG sparkline visualization showing each player's rating journey over time.
Ready to quiz yourself?