This project will guide you through building a real-time leaderboard, a classic use case for Redis. Leaderboards need to:
- Store player scores.
- Maintain an ordered list of players by their score.
- Allow quick updates to scores.
- Efficiently retrieve top players or a player’s rank.
Redis Sorted Sets are perfectly designed for this, as they store unique members with an associated score and keep them sorted.
We will build a simple console application in both Node.js and Python.
Project Objective
Create a leaderboard where:
- Players can submit new scores.
- Scores are automatically updated in real-time.
- You can fetch the top N players.
- You can get a specific player’s rank and score.
- The leaderboard has a maximum size (optional, for cleanup).
Prerequisites
- A running Redis server (from Chapter 2).
- Node.js and
ioredisinstalled. - Python and
redis-pyinstalled.
Step 1: Initialize the Project and Redis Client
Create a new directory for your project.
Node.js:
mkdir redis-leaderboard-nodejs
cd redis-leaderboard-nodejs
npm init -y
npm install ioredis faker # faker is for generating fake player names
Then create a file leaderboard.js.
Python:
mkdir redis-leaderboard-python
cd redis-leaderboard-python
python3 -m venv venv
source venv/bin/activate
pip install redis Faker # Faker is for generating fake player names
Then create a file leaderboard.py.
Step 2: Core Leaderboard Functions (Add/Update Score)
The primary operation for a leaderboard is adding or updating a player’s score. The ZADD command is ideal here. If a member (player ID) already exists, its score is updated.
Node.js (leaderboard.js):
// redis-leaderboard-nodejs/leaderboard.js
const Redis = require('ioredis');
const { faker } = require('@faker-js/faker'); // Use faker.person.fullName()
const redis = new Redis();
const LEADERBOARD_KEY = 'game:global:leaderboard'; // Sorted Set key
async function addPlayerScore(playerId, score) {
try {
// ZADD key score member
// If player exists, score is updated. If not, player is added.
const result = await redis.zadd(LEADERBOARD_KEY, score, playerId);
if (result === 1) {
console.log(`Added new player '${playerId}' with score ${score}.`);
} else {
console.log(`Updated player '${playerId}' score to ${score}.`);
}
return result;
} catch (error) {
console.error(`Error adding score for ${playerId}:`, error);
return null;
}
}
// Example Usage:
// (async () => {
// await addPlayerScore('user:alice', 100);
// await addPlayerScore('user:bob', 150);
// await addPlayerScore('user:alice', 120); // Alice's score updates
// })();
Python (leaderboard.py):
# redis-leaderboard-python/leaderboard.py
import redis
from faker import Faker
fake = Faker()
r = redis.Redis(host='localhost', port=6379, db=0)
LEADERBOARD_KEY = 'game:global:leaderboard_py' # Sorted Set key
def add_player_score(player_id, score):
try:
# ZADD key {member: score, ...}
# redis-py ZADD method expects mapping of {member: score}
# returns the number of elements added (excluding existing updated scores)
result = r.zadd(LEADERBOARD_KEY, {player_id: score})
if result == 1:
print(f"Added new player '{player_id}' with score {score}.")
else:
print(f"Updated player '{player_id}' score to {score}.")
return result
except Exception as e:
print(f"Error adding score for {player_id}: {e}")
return None
# Example Usage:
# add_player_score('user:dave', 200)
# add_player_score('user:eve', 250)
# add_player_score('user:dave', 220) # Dave's score updates
Step 3: Fetching the Leaderboard (Top N Players)
To display the leaderboard, we’ll need to fetch a range of players, typically the ones with the highest scores. ZREVRANGE is perfect for this, as it returns members in descending order of score.
Node.js (leaderboard.js):
// ... (previous code)
async function getTopPlayers(count) {
try {
// ZREVRANGE key start stop WITHSCORES
// 0 - (count - 1) gives us the top 'count' players.
// WITHSCORES returns both member and score.
const topPlayers = await redis.zrevrange(LEADERBOARD_KEY, 0, count - 1, 'WITHSCORES');
// topPlayers is an array like ['player1', 'score1', 'player2', 'score2', ...]
const formattedPlayers = [];
for (let i = 0; i < topPlayers.length; i += 2) {
formattedPlayers.push({
player: topPlayers[i],
score: parseInt(topPlayers[i + 1], 10)
});
}
return formattedPlayers;
} catch (error) {
console.error('Error fetching top players:', error);
return [];
}
}
// ... (Example Usage in main section later)
Python (leaderboard.py):
# ... (previous code)
def get_top_players(count):
try:
# ZREVRANGE key start stop WITHSCORES
# 0 - (count - 1) gives us the top 'count' players.
# withscores=True returns a list of tuples: [(member, score), ...]
top_players_raw = r.zrevrange(LEADERBOARD_KEY, 0, count - 1, withscores=True)
formatted_players = []
for player_bytes, score_float in top_players_raw:
formatted_players.append({
'player': player_bytes.decode('utf-8'),
'score': int(score_float)
})
return formatted_players
except Exception as e:
print(f"Error fetching top players: {e}")
return []
# ... (Example Usage in main section later)
Step 4: Getting a Player’s Rank and Score
To show a player where they stand, we need their exact rank and current score. ZREVRANK gives the 0-based rank (with highest score being rank 0), and ZSCORE gives the score.
Node.js (leaderboard.js):
// ... (previous code)
async function getPlayerRankAndScore(playerId) {
try {
// ZREVRANK key member (returns 0-based rank, highest score first)
const rank = await redis.zrevrank(LEADERBOARD_KEY, playerId);
// ZSCORE key member
const score = await redis.zscore(LEADERBOARD_KEY, playerId);
if (rank !== null && score !== null) {
return { rank: rank + 1, score: parseInt(score, 10) }; // Convert to 1-based rank
} else {
return null; // Player not found
}
} catch (error) {
console.error(`Error fetching rank/score for ${playerId}:`, error);
return null;
}
}
// ... (Example Usage in main section later)
Python (leaderboard.py):
# ... (previous code)
def get_player_rank_and_score(player_id):
try:
# ZREVRANK key member (returns 0-based rank, highest score first)
rank = r.zrevrank(LEADERBOARD_KEY, player_id)
# ZSCORE key member
score = r.zscore(LEADERBOARD_KEY, player_id)
if rank is not None and score is not None:
return {'rank': rank + 1, 'score': int(score)} # Convert to 1-based rank
else:
return None # Player not found
except Exception as e:
print(f"Error fetching rank/score for {player_id}: {e}")
return None
# ... (Example Usage in main section later)
Step 5: Putting It All Together (Main Application Logic)
Now, let’s create a main function to simulate players, update scores, and display the leaderboard.
Node.js (leaderboard.js):
// ... (all previous code)
async function main() {
console.log('--- Real-time Leaderboard Demo (Node.js) ---');
await redis.del(LEADERBOARD_KEY); // Clean up old leaderboard
const players = {};
// Create 10 fake players
for (let i = 0; i < 10; i++) {
const playerId = `player:${faker.person.firstName()}_${faker.string.numeric(4)}`;
const initialScore = faker.number.int({ min: 100, max: 500 });
players[playerId] = initialScore;
await addPlayerScore(playerId, initialScore);
}
console.log('\n--- Initial Leaderboard ---');
let top5 = await getTopPlayers(5);
top5.forEach((p, idx) => console.log(`${idx + 1}. ${p.player} - ${p.score}`));
// --- Simulate score updates ---
console.log('\n--- Simulating score updates ---');
const playerToUpdate = Object.keys(players)[faker.number.int({ min: 0, max: 9 })];
const newScore = players[playerToUpdate] + faker.number.int({ min: 10, max: 100 });
console.log(`\n${playerToUpdate} just scored ${newScore}! (Was ${players[playerToUpdate]})`);
players[playerToUpdate] = newScore; // Update local tracker
await addPlayerScore(playerToUpdate, newScore); // Update in Redis
// --- Display updated leaderboard ---
console.log('\n--- Updated Leaderboard ---');
top5 = await getTopPlayers(5);
top5.forEach((p, idx) => console.log(`${idx + 1}. ${p.player} - ${p.score}`));
// --- Get specific player's rank and score ---
const playerRankAndScore = await getPlayerRankAndScore(playerToUpdate);
if (playerRankAndScore) {
console.log(`\n${playerToUpdate}'s rank: ${playerRankAndScore.rank}, score: ${playerRankAndScore.score}`);
} else {
console.log(`\n${playerToUpdate} not found on leaderboard.`);
}
// --- Add more players and scores to show dynamic ranking ---
console.log('\n--- Adding more players / scores to shake up the board ---');
for (let i = 0; i < 5; i++) {
const newPlayerId = `new_player:${faker.person.firstName()}_${faker.string.numeric(4)}`;
const score = faker.number.int({ min: 300, max: 700 }); // High scores!
await addPlayerScore(newPlayerId, score);
}
console.log('\n--- Final Leaderboard ---');
let finalTop10 = await getTopPlayers(10);
finalTop10.forEach((p, idx) => console.log(`${idx + 1}. ${p.player} - ${p.score}`));
await redis.quit();
console.log('--- Leaderboard Demo Complete ---');
}
main();
Python (leaderboard.py):
# ... (all previous code)
def main_py():
print('--- Real-time Leaderboard Demo (Python) ---')
r.delete(LEADERBOARD_KEY) # Clean up old leaderboard
players = {}
# Create 10 fake players
for i in range(10):
player_id = f"player:{fake.first_name()}_{fake.random_int(1000, 9999)}"
initial_score = fake.random_int(min=100, max=500)
players[player_id] = initial_score
add_player_score(player_id, initial_score)
print('\n--- Initial Leaderboard ---')
top5 = get_top_players(5)
for idx, p in enumerate(top5):
print(f"{idx + 1}. {p['player']} - {p['score']}")
# --- Simulate score updates ---
print('\n--- Simulating score updates ---')
player_to_update = random.choice(list(players.keys()))
new_score = players[player_to_update] + fake.random_int(min=10, max=100)
print(f"\n{player_to_update} just scored {new_score}! (Was {players[player_to_update]})")
players[player_to_update] = new_score # Update local tracker
add_player_score(player_to_update, new_score) # Update in Redis
# --- Display updated leaderboard ---
print('\n--- Updated Leaderboard ---')
top5 = get_top_players(5)
for idx, p in enumerate(top5):
print(f"{idx + 1}. {p['player']} - {p['score']}")
# --- Get specific player's rank and score ---
player_rank_and_score = get_player_rank_and_score(player_to_update)
if player_rank_and_score:
print(f"\n{player_to_update}'s rank: {player_rank_and_score['rank']}, score: {player_rank_and_score['score']}")
else:
print(f"\n{player_to_update} not found on leaderboard.")
# --- Add more players and scores to show dynamic ranking ---
print('\n--- Adding more players / scores to shake up the board ---')
for i in range(5):
new_player_id = f"new_player:{fake.first_name()}_{fake.random_int(1000, 9999)}"
score = fake.random_int(min=300, max: 700) # High scores!
add_player_score(new_player_id, score)
print('\n--- Final Leaderboard ---')
final_top10 = get_top_players(10)
for idx, p in enumerate(final_top10):
print(f"{idx + 1}. {p['player']} - {p['score']}")
r.close()
print('--- Leaderboard Demo Complete ---')
# main_py()
Step 6: Run the Application
Now, run your applications from their respective directories.
Node.js:
node leaderboard.js
Python:
python leaderboard.py
You should see output similar to players being added, scores updating, and the leaderboard rankings changing dynamically.
Further Challenges and Enhancements
Leaderboard Paging:
- Modify
getTopPlayersto acceptstartRankandendRankto fetch a specific page of the leaderboard (e.g., ranks 11-20). (Hint:ZRANGEwith appropriatestartandstopindices).
- Modify
Player Around Me:
- Given a
playerId, retrieve their score and rank, plusNplayers above them andNplayers below them. (Hint:ZRANKto find their 0-based index, then useZRANGEaround that index).
- Given a
Leaderboard Lifetime (TTL):
- Implement an expiration for the entire leaderboard. If it’s a daily leaderboard, set a TTL of 24 hours. Consider how to handle the rollover to a new day. (Hint: Use
EXPIREonLEADERBOARD_KEY).
- Implement an expiration for the entire leaderboard. If it’s a daily leaderboard, set a TTL of 24 hours. Consider how to handle the rollover to a new day. (Hint: Use
Handling Duplicate Player Names:
- Currently, player IDs are
player:<FirstName>_<4-DigitNumber>. What iffaker.person.firstName()generates the same name twice for different numerical IDs? This is fine, asplayerIditself is unique. - Challenge: What if you wanted to track scores for actual players with non-unique display names (e.g., “Player One”) but unique internal
userIds? You might storeuserIdas the Sorted Set member and use a Redis Hash (user:data:<userId>) to store their display name.
- Currently, player IDs are
Concurrent Score Updates (Advanced):
- If multiple players update their scores simultaneously, Redis Sorted Sets handle this atomically. However, if you had more complex logic (e.g., deducting virtual currency before awarding points), you’d need Redis Transactions (
MULTI/EXEC/WATCH) to ensure atomicity.
- If multiple players update their scores simultaneously, Redis Sorted Sets handle this atomically. However, if you had more complex logic (e.g., deducting virtual currency before awarding points), you’d need Redis Transactions (
By completing this project and its challenges, you’ve gained hands-on experience with Redis Sorted Sets and their application in a common real-world scenario. This lays a strong foundation for using Redis in your own applications.
Our next guided project will focus on Distributed Caching with Rate Limiting, another critical use case for Redis.