Guided Project 1: Building a Real-time Leaderboard

This project will guide you through building a real-time leaderboard, a classic use case for Redis. Leaderboards need to:

  1. Store player scores.
  2. Maintain an ordered list of players by their score.
  3. Allow quick updates to scores.
  4. 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 ioredis installed.
  • Python and redis-py installed.

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

  1. Leaderboard Paging:

    • Modify getTopPlayers to accept startRank and endRank to fetch a specific page of the leaderboard (e.g., ranks 11-20). (Hint: ZRANGE with appropriate start and stop indices).
  2. Player Around Me:

    • Given a playerId, retrieve their score and rank, plus N players above them and N players below them. (Hint: ZRANK to find their 0-based index, then use ZRANGE around that index).
  3. 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 EXPIRE on LEADERBOARD_KEY).
  4. Handling Duplicate Player Names:

    • Currently, player IDs are player:<FirstName>_<4-DigitNumber>. What if faker.person.firstName() generates the same name twice for different numerical IDs? This is fine, as playerId itself 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 store userId as the Sorted Set member and use a Redis Hash (user:data:<userId>) to store their display name.
  5. 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.

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.