home / skills / amnadtaowsoam / cerebraskills / leaderboards

This skill ranks players across global, friends, and time-based leaderboards using Redis-backed scoring to boost competition and engagement.

npx playbooks add skill amnadtaowsoam/cerebraskills --skill leaderboards

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
17.8 KB
---
name: Leaderboards
description: Ranking players based on scores or achievements using global, friends, and time-based leaderboards with Redis for high-performance ranking systems.
---

# Leaderboards

> **Current Level:** Intermediate  
> **Domain:** Gaming / Backend

---

## Overview

Leaderboards rank players based on scores or achievements. This guide covers global, friends, and time-based leaderboards with Redis for performance, providing competitive ranking systems that motivate players and drive engagement.

---

---

## Core Concepts

### Leaderboard Types

#### Global Leaderboard
- All players worldwide
- Highest scores overall
- Most competitive

### Friends Leaderboard
- Only friends/connections
- Social comparison
- Personalized

### Time-based Leaderboard
- Daily, weekly, monthly
- Resets periodically
- Fresh competition

### Seasonal Leaderboard
- Limited time events
- Special rewards
- Themed competitions

## Database Schema

```sql
-- players table
CREATE TABLE players (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  username VARCHAR(255) UNIQUE NOT NULL,
  display_name VARCHAR(255),
  avatar_url VARCHAR(500),
  
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  
  INDEX idx_username (username)
);

-- scores table
CREATE TABLE scores (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  player_id UUID REFERENCES players(id) ON DELETE CASCADE,
  leaderboard_id VARCHAR(100) NOT NULL,
  
  score BIGINT NOT NULL,
  metadata JSONB,
  
  created_at TIMESTAMP DEFAULT NOW(),
  
  INDEX idx_player_leaderboard (player_id, leaderboard_id),
  INDEX idx_leaderboard_score (leaderboard_id, score DESC)
);

-- leaderboards table
CREATE TABLE leaderboards (
  id VARCHAR(100) PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  type VARCHAR(50) NOT NULL,
  
  reset_frequency VARCHAR(50),
  last_reset TIMESTAMP,
  next_reset TIMESTAMP,
  
  active BOOLEAN DEFAULT TRUE,
  
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- friendships table
CREATE TABLE friendships (
  player_id UUID REFERENCES players(id) ON DELETE CASCADE,
  friend_id UUID REFERENCES players(id) ON DELETE CASCADE,
  
  status VARCHAR(50) DEFAULT 'pending',
  
  created_at TIMESTAMP DEFAULT NOW(),
  
  PRIMARY KEY (player_id, friend_id),
  INDEX idx_player (player_id),
  INDEX idx_friend (friend_id)
);
```

## Score Submission

```typescript
// services/leaderboard.service.ts
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis';

const db = new PrismaClient();
const redis = new Redis(process.env.REDIS_URL!);

export class LeaderboardService {
  async submitScore(
    playerId: string,
    leaderboardId: string,
    score: number,
    metadata?: any
  ): Promise<void> {
    // Validate score
    if (score < 0) {
      throw new Error('Invalid score');
    }

    // Check for cheating (basic)
    const isValid = await this.validateScore(playerId, score);
    if (!isValid) {
      throw new Error('Score validation failed');
    }

    // Save to database
    await db.score.create({
      data: {
        playerId,
        leaderboardId,
        score,
        metadata
      }
    });

    // Update Redis sorted set
    await redis.zadd(`leaderboard:${leaderboardId}`, score, playerId);

    // Update player's best score
    await this.updateBestScore(playerId, leaderboardId, score);
  }

  private async updateBestScore(
    playerId: string,
    leaderboardId: string,
    newScore: number
  ): Promise<void> {
    const currentBest = await redis.zscore(
      `leaderboard:${leaderboardId}:best`,
      playerId
    );

    if (!currentBest || newScore > parseInt(currentBest)) {
      await redis.zadd(
        `leaderboard:${leaderboardId}:best`,
        newScore,
        playerId
      );
    }
  }

  private async validateScore(playerId: string, score: number): Promise<boolean> {
    // Get recent scores
    const recentScores = await db.score.findMany({
      where: {
        playerId,
        createdAt: {
          gte: new Date(Date.now() - 60 * 60 * 1000) // Last hour
        }
      },
      orderBy: { score: 'desc' },
      take: 10
    });

    // Check for impossible score jumps
    if (recentScores.length > 0) {
      const maxRecent = Math.max(...recentScores.map(s => s.score));
      if (score > maxRecent * 10) {
        return false; // Suspicious
      }
    }

    return true;
  }
}
```

## Ranking Algorithms

```typescript
// Get rankings from Redis
export class RankingService {
  async getGlobalRankings(
    leaderboardId: string,
    limit: number = 100,
    offset: number = 0
  ): Promise<LeaderboardEntry[]> {
    // Get top players from Redis (sorted set)
    const results = await redis.zrevrange(
      `leaderboard:${leaderboardId}:best`,
      offset,
      offset + limit - 1,
      'WITHSCORES'
    );

    const entries: LeaderboardEntry[] = [];

    for (let i = 0; i < results.length; i += 2) {
      const playerId = results[i];
      const score = parseInt(results[i + 1]);
      const rank = offset + (i / 2) + 1;

      // Get player info
      const player = await db.player.findUnique({
        where: { id: playerId }
      });

      if (player) {
        entries.push({
          rank,
          playerId,
          username: player.username,
          displayName: player.displayName,
          avatarUrl: player.avatarUrl,
          score
        });
      }
    }

    return entries;
  }

  async getPlayerRank(playerId: string, leaderboardId: string): Promise<number> {
    const rank = await redis.zrevrank(
      `leaderboard:${leaderboardId}:best`,
      playerId
    );

    return rank !== null ? rank + 1 : -1;
  }

  async getPlayersAroundRank(
    playerId: string,
    leaderboardId: string,
    range: number = 5
  ): Promise<LeaderboardEntry[]> {
    const playerRank = await this.getPlayerRank(playerId, leaderboardId);

    if (playerRank === -1) {
      return [];
    }

    const start = Math.max(0, playerRank - range - 1);
    const end = playerRank + range - 1;

    return this.getGlobalRankings(leaderboardId, end - start + 1, start);
  }
}

interface LeaderboardEntry {
  rank: number;
  playerId: string;
  username: string;
  displayName: string | null;
  avatarUrl: string | null;
  score: number;
}
```

## Friends Leaderboard

```typescript
// services/friends-leaderboard.service.ts
export class FriendsLeaderboardService {
  async getFriendsRankings(
    playerId: string,
    leaderboardId: string
  ): Promise<LeaderboardEntry[]> {
    // Get friend IDs
    const friendships = await db.friendship.findMany({
      where: {
        OR: [
          { playerId, status: 'accepted' },
          { friendId: playerId, status: 'accepted' }
        ]
      }
    });

    const friendIds = friendships.map(f =>
      f.playerId === playerId ? f.friendId : f.playerId
    );

    // Include self
    friendIds.push(playerId);

    // Get scores for all friends
    const scores = await redis.zrevrange(
      `leaderboard:${leaderboardId}:best`,
      0,
      -1,
      'WITHSCORES'
    );

    const friendScores: Array<{ playerId: string; score: number }> = [];

    for (let i = 0; i < scores.length; i += 2) {
      const id = scores[i];
      if (friendIds.includes(id)) {
        friendScores.push({
          playerId: id,
          score: parseInt(scores[i + 1])
        });
      }
    }

    // Sort by score
    friendScores.sort((a, b) => b.score - a.score);

    // Get player info
    const entries: LeaderboardEntry[] = [];

    for (let i = 0; i < friendScores.length; i++) {
      const { playerId: id, score } = friendScores[i];
      
      const player = await db.player.findUnique({
        where: { id }
      });

      if (player) {
        entries.push({
          rank: i + 1,
          playerId: id,
          username: player.username,
          displayName: player.displayName,
          avatarUrl: player.avatarUrl,
          score
        });
      }
    }

    return entries;
  }
}
```

## Time-based Leaderboards

```typescript
// services/time-based-leaderboard.service.ts
export class TimeBasedLeaderboardService {
  async getDailyLeaderboard(leaderboardId: string): Promise<LeaderboardEntry[]> {
    const today = new Date().toISOString().split('T')[0];
    const key = `leaderboard:${leaderboardId}:daily:${today}`;

    return this.getRankingsFromKey(key);
  }

  async getWeeklyLeaderboard(leaderboardId: string): Promise<LeaderboardEntry[]> {
    const weekNumber = this.getWeekNumber(new Date());
    const key = `leaderboard:${leaderboardId}:weekly:${weekNumber}`;

    return this.getRankingsFromKey(key);
  }

  async getMonthlyLeaderboard(leaderboardId: string): Promise<LeaderboardEntry[]> {
    const month = new Date().toISOString().slice(0, 7);
    const key = `leaderboard:${leaderboardId}:monthly:${month}`;

    return this.getRankingsFromKey(key);
  }

  async submitDailyScore(
    playerId: string,
    leaderboardId: string,
    score: number
  ): Promise<void> {
    const today = new Date().toISOString().split('T')[0];
    const key = `leaderboard:${leaderboardId}:daily:${today}`;

    await redis.zadd(key, score, playerId);
    await redis.expire(key, 7 * 24 * 60 * 60); // Keep for 7 days
  }

  private async getRankingsFromKey(key: string): Promise<LeaderboardEntry[]> {
    const results = await redis.zrevrange(key, 0, 99, 'WITHSCORES');

    const entries: LeaderboardEntry[] = [];

    for (let i = 0; i < results.length; i += 2) {
      const playerId = results[i];
      const score = parseInt(results[i + 1]);

      const player = await db.player.findUnique({
        where: { id: playerId }
      });

      if (player) {
        entries.push({
          rank: (i / 2) + 1,
          playerId,
          username: player.username,
          displayName: player.displayName,
          avatarUrl: player.avatarUrl,
          score
        });
      }
    }

    return entries;
  }

  private getWeekNumber(date: Date): string {
    const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
    const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
    const weekNumber = Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
    
    return `${date.getFullYear()}-W${weekNumber}`;
  }
}
```

## Real-time Updates

```typescript
// services/leaderboard-realtime.service.ts
import { Server as SocketIOServer } from 'socket.io';

export class LeaderboardRealtimeService {
  constructor(private io: SocketIOServer) {
    this.setupHandlers();
  }

  private setupHandlers(): void {
    this.io.on('connection', (socket) => {
      socket.on('subscribe-leaderboard', (leaderboardId: string) => {
        socket.join(`leaderboard:${leaderboardId}`);
      });

      socket.on('unsubscribe-leaderboard', (leaderboardId: string) => {
        socket.leave(`leaderboard:${leaderboardId}`);
      });
    });
  }

  async broadcastScoreUpdate(
    leaderboardId: string,
    entry: LeaderboardEntry
  ): Promise<void> {
    this.io.to(`leaderboard:${leaderboardId}`).emit('score-update', entry);
  }

  async broadcastRankChange(
    leaderboardId: string,
    playerId: string,
    oldRank: number,
    newRank: number
  ): Promise<void> {
    this.io.to(`leaderboard:${leaderboardId}`).emit('rank-change', {
      playerId,
      oldRank,
      newRank
    });
  }
}
```

## Pagination

```typescript
// API endpoint with pagination
export async function getLeaderboard(req: Request, res: Response) {
  const { leaderboardId } = req.params;
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 50;
  const offset = (page - 1) * limit;

  const rankings = await rankingService.getGlobalRankings(
    leaderboardId,
    limit,
    offset
  );

  const total = await redis.zcard(`leaderboard:${leaderboardId}:best`);

  res.json({
    data: rankings,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  });
}
```

## Cheating Prevention

```typescript
// services/anti-cheat.service.ts
export class AntiCheatService {
  async detectCheating(playerId: string, score: number): Promise<boolean> {
    // Check score distribution
    const avgScore = await this.getAverageScore(playerId);
    if (score > avgScore * 5) {
      await this.flagSuspiciousScore(playerId, score);
      return true;
    }

    // Check submission frequency
    const recentSubmissions = await this.getRecentSubmissions(playerId);
    if (recentSubmissions > 100) {
      await this.flagSuspiciousActivity(playerId);
      return true;
    }

    // Check for impossible scores
    const maxPossibleScore = await this.getMaxPossibleScore();
    if (score > maxPossibleScore) {
      await this.flagImpossibleScore(playerId, score);
      return true;
    }

    return false;
  }

  private async getAverageScore(playerId: string): Promise<number> {
    const scores = await db.score.findMany({
      where: { playerId },
      select: { score: true }
    });

    if (scores.length === 0) return 0;

    const sum = scores.reduce((acc, s) => acc + s.score, 0);
    return sum / scores.length;
  }

  private async getRecentSubmissions(playerId: string): Promise<number> {
    return db.score.count({
      where: {
        playerId,
        createdAt: {
          gte: new Date(Date.now() - 60 * 60 * 1000) // Last hour
        }
      }
    });
  }

  private async getMaxPossibleScore(): Promise<number> {
    // Game-specific logic
    return 1000000;
  }

  private async flagSuspiciousScore(playerId: string, score: number): Promise<void> {
    await db.suspiciousActivity.create({
      data: {
        playerId,
        type: 'suspicious_score',
        details: { score }
      }
    });
  }

  private async flagSuspiciousActivity(playerId: string): Promise<void> {
    await db.suspiciousActivity.create({
      data: {
        playerId,
        type: 'high_frequency_submissions'
      }
    });
  }

  private async flagImpossibleScore(playerId: string, score: number): Promise<void> {
    await db.suspiciousActivity.create({
      data: {
        playerId,
        type: 'impossible_score',
        details: { score }
      }
    });
  }
}
```

---

## Quick Start

### Redis Leaderboard

```javascript
const redis = require('redis')
const client = redis.createClient()

// Add score
await client.zAdd('leaderboard', {
  score: 1000,
  value: 'player-123'
})

// Get top 10
const topPlayers = await client.zRange('leaderboard', 0, 9, {
  REV: true,  // Descending
  WITHSCORES: true
})

// Get player rank
const rank = await client.zRevRank('leaderboard', 'player-123')
```

### Leaderboard API

```javascript
app.get('/leaderboard', async (req, res) => {
  const { type = 'global', limit = 10, offset = 0 } = req.query
  
  const key = `leaderboard:${type}`
  const players = await client.zRange(key, offset, offset + limit - 1, {
    REV: true,
    WITHSCORES: true
  })
  
  res.json(players.map((player, index) => ({
    rank: offset + index + 1,
    playerId: player.value,
    score: player.score
  })))
})
```

---

## Production Checklist

- [ ] **Redis Setup**: Redis configured for leaderboards
- [ ] **Score Validation**: Validate all submitted scores
- [ ] **Anti-cheat**: Implement cheat detection
- [ ] **Pagination**: Always paginate leaderboard results
- [ ] **Caching**: Cache leaderboard data appropriately
- [ ] **Real-time Updates**: WebSocket for live updates
- [ ] **Time-based**: Support daily/weekly/monthly leaderboards
- [ ] **Friends**: Friends-only leaderboards
- [ ] **Performance**: Optimize for high read loads
- [ ] **Monitoring**: Monitor leaderboard performance
- [ ] **Testing**: Test with high score volumes
- [ ] **Documentation**: Document leaderboard rules

---

## Anti-patterns

### ❌ Don't: No Score Validation

```javascript
// ❌ Bad - Trust user input
await client.zAdd('leaderboard', {
  score: userSubmittedScore,  // Could be hacked!
  value: playerId
})
```

```javascript
// ✅ Good - Validate scores
function validateScore(score, gameData) {
  const maxPossibleScore = calculateMaxScore(gameData)
  if (score > maxPossibleScore) {
    throw new Error('Impossible score')
  }
  return score
}

const validScore = validateScore(userSubmittedScore, gameData)
await client.zAdd('leaderboard', {
  score: validScore,
  value: playerId
})
```

### ❌ Don't: No Pagination

```javascript
// ❌ Bad - Load all players
const allPlayers = await client.zRange('leaderboard', 0, -1)  // All!
```

```javascript
// ✅ Good - Paginate
const pageSize = 10
const offset = (page - 1) * pageSize
const players = await client.zRange(
  'leaderboard',
  offset,
  offset + pageSize - 1,
  { REV: true }
)
```

### ❌ Don't: No Caching

```javascript
// ❌ Bad - Query Redis every time
app.get('/leaderboard', async (req, res) => {
  const players = await client.zRange('leaderboard', 0, 9)  // Every request!
  res.json(players)
})
```

```javascript
// ✅ Good - Cache top players
const cache = new Map()

app.get('/leaderboard', async (req, res) => {
  if (cache.has('top-10')) {
    return res.json(cache.get('top-10'))
  }
  
  const players = await client.zRange('leaderboard', 0, 9, { REV: true })
  cache.set('top-10', players)
  setTimeout(() => cache.delete('top-10'), 60000)  // 1 min cache
  
  res.json(players)
})
```

---

## Integration Points

- **Redis Caching** (`04-database/redis-caching/`) - Redis patterns
- **Real-time Features** (`34-real-time-features/`) - Live updates
- **Game Analytics** (`38-gaming-features/game-analytics/`) - Score analytics

---

## Further Reading

- [Redis Sorted Sets](https://redis.io/docs/data-types/sorted-sets/)
- [Leaderboard Patterns](https://redis.io/docs/stack/search/leaderboard/)
- [Gaming Leaderboards](https://www.gamedeveloper.com/design/leaderboard-design-patterns)
9. **Analytics** - Track leaderboard engagement
10. **Performance** - Optimize for scale

## Resources

- [Redis Sorted Sets](https://redis.io/docs/data-types/sorted-sets/)
- [Leaderboard Design Patterns](https://redis.com/solutions/use-cases/leaderboards/)
- [Game Leaderboards](https://aws.amazon.com/blogs/gametech/building-game-leaderboards-with-amazon-elasticache-for-redis/)

Overview

This skill implements high-performance player leaderboards using Redis and a relational database. It supports global, friends, and time-based (daily/weekly/monthly/seasonal) rankings and includes score validation, pagination, real-time updates, and basic anti-cheat checks. The design balances speed with persistent auditing for analytics and dispute resolution.

How this skill works

Scores are submitted to both the primary database for persistence and Redis sorted sets for fast ranking queries. Best scores are kept in dedicated Redis keys; daily/weekly/monthly leaderboards use time-stamped keys with TTLs. Services expose routines to fetch global rankings, friends-only views, players-around-you, and paginated endpoints, while a socket layer broadcasts real-time updates and rank changes. An anti-cheat service performs heuristic checks and flags suspicious activity into the database.

When to use it

  • Competitive multiplayer games needing large-scale ranking
  • Social features showing friends-only leaderboards
  • Time-limited events and seasonal competitions
  • Systems requiring real-time score updates and notifications
  • Apps needing fast reads with persistent auditing

Best practices

  • Write every score to the database for auditability and to Redis for low-latency reads
  • Store per-period leaderboards in separate Redis keys and set TTLs to control retention
  • Keep a separate sorted set for each leaderboard's best scores to simplify ranking queries
  • Validate scores on submission and run heuristic anti-cheat checks before accepting high jumps
  • Paginate leaderboard queries server-side and cache hot pages to reduce Redis load
  • Emit socket events for subscribers to avoid polling and update clients incrementally

Example use cases

  • Global top-100 leaderboard for a high-score arcade game with daily resets
  • Friends leaderboard showing relative position among a player’s social graph
  • Weekly and monthly leaderboards for seasonal rewards and special events
  • Real-time score feeds for live tournaments via WebSocket channels
  • Paginated API endpoints for mobile clients and web leaderboards

FAQ

How does this handle ties in scores?

Redis sorted sets order members deterministically by member value when scores tie; tie-breakers can be implemented by embedding timestamps or unique sequence numbers into scores or member values.

What prevents cheating at scale?

Use multi-layer checks: basic heuristic validation on submission, frequency and distribution analysis in anti-cheat service, persistent logging for review, and game-specific limits for maximum possible scores; escalate flagged cases for manual review.