Build real-time leaderboards with Redis sorted sets. Perfect for games, competitions, and analytics.
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class Leaderboard { constructor(private key: string) {} // Add or update a player's score async setScore(playerId: string, score: number) { await redis.zadd(this.key, score, playerId); } // Increment score (e.g., +10 points) async addScore(playerId: string, points: number) { return redis.zincrby(this.key, points, playerId); } // Get player's rank (0-indexed, lowest score = rank 0) async getRank(playerId: string): Promise<number | null> { const rank = await redis.zrevrank(this.key, playerId); return rank !== null ? rank + 1 : null; // 1-indexed } // Get player's score async getScore(playerId: string): Promise<number | null> { const score = await redis.zscore(this.key, playerId); return score ? parseFloat(score) : null; } // Get top N players async getTop(n: number): Promise<Array<{ player: string; score: number; rank: number }>> { const results = await redis.zrevrange(this.key, 0, n - 1, 'WITHSCORES'); return this.parseResults(results, 1); } // Get players around a specific player async getAround(playerId: string, range: number = 5) { const rank = await redis.zrevrank(this.key, playerId); if (rank === null) return []; const start = Math.max(0, rank - range); const end = rank + range; const results = await redis.zrevrange(this.key, start, end, 'WITHSCORES'); return this.parseResults(results, start + 1); } private parseResults(results: string[], startRank: number) { const entries = []; for (let i = 0; i < results.length; i += 2) { entries.push({ player: results[i], score: parseFloat(results[i + 1]), rank: startRank + (i / 2), }); } return entries; } } // Usage const leaderboard = new Leaderboard('game:weekly'); await leaderboard.setScore('alice', 1500); await leaderboard.setScore('bob', 2300); await leaderboard.addScore('alice', 100); // Now 1600 const top10 = await leaderboard.getTop(10); // [{ player: 'bob', score: 2300, rank: 1 }, ...] const myRank = await leaderboard.getRank('alice'); // 2
Weekly, monthly, or daily leaderboards that reset automatically:
function getWeeklyKey(): string { const now = new Date(); const year = now.getFullYear(); const week = getWeekNumber(now); return `leaderboard:weekly:${year}:w${week}`; } function getDailyKey(): string { const now = new Date(); return `leaderboard:daily:${now.toISOString().split('T')[0]}`; } // Set expiry so old leaderboards auto-delete async function addToWeeklyLeaderboard(playerId: string, score: number) { const key = getWeeklyKey(); await redis.zadd(key, score, playerId); await redis.expire(key, 60 * 60 * 24 * 14); // Keep 2 weeks }
Track multiple scores per player:
class MultiLeaderboard { // Update all leaderboards atomically async recordGame(playerId: string, stats: { score: number; kills: number; wins: number; }) { const multi = redis.multi(); multi.zincrby('lb:score:alltime', stats.score, playerId); multi.zincrby('lb:kills:alltime', stats.kills, playerId); multi.zincrby('lb:wins:alltime', stats.wins, playerId); multi.zincrby(`lb:score:${getDailyKey()}`, stats.score, playerId); await multi.exec(); } // Get player's rankings across all leaderboards async getPlayerRankings(playerId: string) { const [scoreRank, killsRank, winsRank] = await Promise.all([ redis.zrevrank('lb:score:alltime', playerId), redis.zrevrank('lb:kills:alltime', playerId), redis.zrevrank('lb:wins:alltime', playerId), ]); return { score: scoreRank !== null ? scoreRank + 1 : null, kills: killsRank !== null ? killsRank + 1 : null, wins: winsRank !== null ? winsRank + 1 : null, }; } }
Broadcast leaderboard changes:
// When score changes async function updateAndBroadcast(playerId: string, newScore: number) { const oldRank = await leaderboard.getRank(playerId); await leaderboard.setScore(playerId, newScore); const newRank = await leaderboard.getRank(playerId); if (oldRank !== newRank) { await redis.publish('leaderboard:updates', JSON.stringify({ playerId, oldRank, newRank, score: newScore, })); } } // Subscribe to updates (e.g., in WebSocket server) const sub = new Redis(process.env.ARCTICKEY_URL); sub.subscribe('leaderboard:updates'); sub.on('message', (channel, message) => { const update = JSON.parse(message); // Broadcast to connected clients io.emit('leaderboard:update', update); });
Combine leaderboard with player data:
async function getTopWithDetails(n: number) { const top = await leaderboard.getTop(n); // Fetch player details in parallel const pipeline = redis.pipeline(); for (const entry of top) { pipeline.hgetall(`player:${entry.player}`); } const details = await pipeline.exec(); return top.map((entry, i) => ({ ...entry, details: details[i][1] as Record<string, string>, })); } // Result: // [ // { player: 'bob', score: 2300, rank: 1, details: { name: 'Bob', avatar: '...' } }, // ... // ]
For large leaderboards:
async function getPage(page: number, pageSize: number = 20) { const start = (page - 1) * pageSize; const end = start + pageSize - 1; const [results, total] = await Promise.all([ redis.zrevrange('leaderboard', start, end, 'WITHSCORES'), redis.zcard('leaderboard'), ]); return { entries: parseResults(results, start + 1), page, pageSize, totalPages: Math.ceil(total / pageSize), totalPlayers: total, }; }
lb:game:mode:period
Leaderboards & Rankings
Build real-time leaderboards with Redis sorted sets. Perfect for games, competitions, and analytics.
Why Redis for Leaderboards?#
Basic Leaderboard#
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class Leaderboard { constructor(private key: string) {} // Add or update a player's score async setScore(playerId: string, score: number) { await redis.zadd(this.key, score, playerId); } // Increment score (e.g., +10 points) async addScore(playerId: string, points: number) { return redis.zincrby(this.key, points, playerId); } // Get player's rank (0-indexed, lowest score = rank 0) async getRank(playerId: string): Promise<number | null> { const rank = await redis.zrevrank(this.key, playerId); return rank !== null ? rank + 1 : null; // 1-indexed } // Get player's score async getScore(playerId: string): Promise<number | null> { const score = await redis.zscore(this.key, playerId); return score ? parseFloat(score) : null; } // Get top N players async getTop(n: number): Promise<Array<{ player: string; score: number; rank: number }>> { const results = await redis.zrevrange(this.key, 0, n - 1, 'WITHSCORES'); return this.parseResults(results, 1); } // Get players around a specific player async getAround(playerId: string, range: number = 5) { const rank = await redis.zrevrank(this.key, playerId); if (rank === null) return []; const start = Math.max(0, rank - range); const end = rank + range; const results = await redis.zrevrange(this.key, start, end, 'WITHSCORES'); return this.parseResults(results, start + 1); } private parseResults(results: string[], startRank: number) { const entries = []; for (let i = 0; i < results.length; i += 2) { entries.push({ player: results[i], score: parseFloat(results[i + 1]), rank: startRank + (i / 2), }); } return entries; } } // Usage const leaderboard = new Leaderboard('game:weekly'); await leaderboard.setScore('alice', 1500); await leaderboard.setScore('bob', 2300); await leaderboard.addScore('alice', 100); // Now 1600 const top10 = await leaderboard.getTop(10); // [{ player: 'bob', score: 2300, rank: 1 }, ...] const myRank = await leaderboard.getRank('alice'); // 2Time-Based Leaderboards#
Weekly, monthly, or daily leaderboards that reset automatically:
function getWeeklyKey(): string { const now = new Date(); const year = now.getFullYear(); const week = getWeekNumber(now); return `leaderboard:weekly:${year}:w${week}`; } function getDailyKey(): string { const now = new Date(); return `leaderboard:daily:${now.toISOString().split('T')[0]}`; } // Set expiry so old leaderboards auto-delete async function addToWeeklyLeaderboard(playerId: string, score: number) { const key = getWeeklyKey(); await redis.zadd(key, score, playerId); await redis.expire(key, 60 * 60 * 24 * 14); // Keep 2 weeks }Multi-Leaderboard System#
Track multiple scores per player:
class MultiLeaderboard { // Update all leaderboards atomically async recordGame(playerId: string, stats: { score: number; kills: number; wins: number; }) { const multi = redis.multi(); multi.zincrby('lb:score:alltime', stats.score, playerId); multi.zincrby('lb:kills:alltime', stats.kills, playerId); multi.zincrby('lb:wins:alltime', stats.wins, playerId); multi.zincrby(`lb:score:${getDailyKey()}`, stats.score, playerId); await multi.exec(); } // Get player's rankings across all leaderboards async getPlayerRankings(playerId: string) { const [scoreRank, killsRank, winsRank] = await Promise.all([ redis.zrevrank('lb:score:alltime', playerId), redis.zrevrank('lb:kills:alltime', playerId), redis.zrevrank('lb:wins:alltime', playerId), ]); return { score: scoreRank !== null ? scoreRank + 1 : null, kills: killsRank !== null ? killsRank + 1 : null, wins: winsRank !== null ? winsRank + 1 : null, }; } }Real-Time Updates with Pub/Sub#
Broadcast leaderboard changes:
// When score changes async function updateAndBroadcast(playerId: string, newScore: number) { const oldRank = await leaderboard.getRank(playerId); await leaderboard.setScore(playerId, newScore); const newRank = await leaderboard.getRank(playerId); if (oldRank !== newRank) { await redis.publish('leaderboard:updates', JSON.stringify({ playerId, oldRank, newRank, score: newScore, })); } } // Subscribe to updates (e.g., in WebSocket server) const sub = new Redis(process.env.ARCTICKEY_URL); sub.subscribe('leaderboard:updates'); sub.on('message', (channel, message) => { const update = JSON.parse(message); // Broadcast to connected clients io.emit('leaderboard:update', update); });With Player Details#
Combine leaderboard with player data:
async function getTopWithDetails(n: number) { const top = await leaderboard.getTop(n); // Fetch player details in parallel const pipeline = redis.pipeline(); for (const entry of top) { pipeline.hgetall(`player:${entry.player}`); } const details = await pipeline.exec(); return top.map((entry, i) => ({ ...entry, details: details[i][1] as Record<string, string>, })); } // Result: // [ // { player: 'bob', score: 2300, rank: 1, details: { name: 'Bob', avatar: '...' } }, // ... // ]Pagination#
For large leaderboards:
async function getPage(page: number, pageSize: number = 20) { const start = (page - 1) * pageSize; const end = start + pageSize - 1; const [results, total] = await Promise.all([ redis.zrevrange('leaderboard', start, end, 'WITHSCORES'), redis.zcard('leaderboard'), ]); return { entries: parseResults(results, start + 1), page, pageSize, totalPages: Math.ceil(total / pageSize), totalPlayers: total, }; }Best Practices#
lb:game:mode:period