New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

leaderboard-engine

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

leaderboard-engine

Production-grade leaderboard engine using MongoDB (truth layer) + Redis ZSET (ranking engine). Cursor pagination, window queries, filtering, multi-leaderboard support.

latest
npmnpm
Version
1.0.1
Version published
Maintainers
1
Created
Source

🏆 leaderboard-engine

A production-grade leaderboard engine built on Redis Sorted Sets + MongoDB.
Scoring pipeline · Auto-tiers · Cursor pagination · Team & Solo · Hybrid filtering · Crash recovery

Quick Start · API Reference · Architecture · Use Cases

[!WARNING] This project is in beta / testing phase. APIs may change between minor versions. Not recommended for production use yet. Contributions, bug reports, and feedback are very welcome!

Table of Contents

What Is This?

A TypeScript npm package that gives you a production-ready leaderboard out of the box. Under the hood:

LayerTechnologyRole
RankingRedis Sorted Sets (ZSET)O(log N) rank lookups, O(log N + K) range queries
PersistenceMongoDBTruth layer, metadata, filtering, crash recovery
ApplicationTypeScriptSync, pagination, enrichment, tier logic

One score goes into Redis. Everything else lives in MongoDB.

You choose which single numeric value to rank by — tierScore for global rankings, or any stat like kills, damage, matchesPlayed for special leaderboards.

What Can It Do?

Core Features

FeatureDescription
Scoring PipelineConfig-driven: map raw data fields → weighted score
Auto Tier AssignmentScore falls in band → tier assigned automatically
Band NormalizationScores capped within tier [floor, cap] ranges
Batch NormalizationCross-participant min-max normalization
RecalculateRe-run pipeline on stored rawData (after config change)
Add / Update / RemoveFull CRUD for participants with atomic operations
Increment ScoresAtomic ZINCRBY + Mongo $inc — safe under concurrency
Bulk UpdatesPipeline + bulkWrite for high-throughput batch operations
Get RankO(log N) single-player rank lookup
Top NGet the first N entries
Paginated LeaderboardCursor-based pagination (no skip/limit!)
Around PlayerWindow query — "show me 10 above and 10 below this player"
Hybrid FilteringFilter by metadata (region, clan, etc.) via Redis+Mongo
Tier FilteringFilter by tier (S, A, B, C)
Team & Solo`participantType: 'Team'
Tier SystemS/A/B/C score-band tiers for global leaderboards
Score BreakdownRPS, CR, KE, LB component scores with normalization
Crash RecoveryAuto-rebuild Redis from MongoDB if key is missing
Metrics HooksPlug in your own metrics collector
Logger HooksPlug in winston, pino, console — anything

What It Does NOT Do (By Design)

Anti-PatternWhy We Avoid It
❌ Skip/limit paginationO(N) scan — unusable at scale
❌ MongoDB-only rankingNo ZSET = no O(log N) rank lookups
❌ Heavy read aggregationsAggregation pipelines are slow for real-time reads
❌ Synchronous blockingAll I/O is async/await
❌ Score computationThis module ranks pre-computed scores — computation is your responsibility

What Types of Leaderboards?

This module supports any leaderboard that can be sorted by a single numeric key. Here are concrete examples:

🎮 Esports & Gaming

LeaderboardSort KeyParticipant Type
Global Tier Rankings (BGMI, FreeFire, CODM)tierScore (composite 0-100)Team
Kill LeaderboardkillsTeam or User
Damage LeaderboarddamageUser
Win StreakwinStreakTeam
Weekly Tournament RankingstournamentPointsTeam

🏋️ Fitness & Health

LeaderboardSort KeyParticipant Type
Steps ChallengetotalStepsUser
Calories BurnedcaloriesBurnedUser
Workout StreakstreakDaysUser

📊 Business & SaaS

LeaderboardSort KeyParticipant Type
Sales LeaderboardrevenueUser
Support Tickets ResolvedticketsResolvedUser
Team Sprint PointssprintPointsTeam
Customer ReferralsreferralCountUser

🎓 Education

LeaderboardSort KeyParticipant Type
Quiz ScorestotalScoreUser
Course CompletionscoursesCompletedUser
Coding ChallengesproblemsSolvedUser

🎯 Key Principle

If you can express your ranking as a single number, this module can rank it. Everything else (name, avatar, region, tier, breakdown, stats) is metadata.

Prerequisites

RequirementVersion
Node.js>= 16.x
Redis>= 6.x
MongoDB>= 5.x
TypeScript>= 4.7 (optional, JS works too)

Installation

npm install leaderboard-engine

[!NOTE] This package is published on npm and in beta. Report issues on GitHub.

Quick Start

The engine computes scores from raw data using your ScoringConfig — no manual score computation needed.

import { LeaderboardEngine } from 'leaderboard-engine';

const engine = new LeaderboardEngine({
  leaderboardName: 'bgmi-global-weekly',
  mongoUri: 'mongodb://localhost:27017/leaderboard',
  redisOptions: { host: '127.0.0.1', port: 6379 },
  participantType: 'Team',

  // THE SCORING CONFIG — maps raw data fields → single score
  scoring: {
    fields: [
      { key: 'placementPoints', weight: 0.40, cap: 100 },
      { key: 'killPoints',      weight: 0.30 },
      { key: 'consistency',     weight: 0.20, cap: 100 },
      { key: 'matchesPlayed',   weight: 0.10, normalize: true },
    ],
    useTiers: true,
    tierBands: [
      { id: 'S', name: 'S Tier', floor: 75, cap: 100, color: '#F50414' },
      { id: 'A', name: 'A Tier', floor: 50, cap: 75,  color: '#FF6B00' },
      { id: 'B', name: 'B Tier', floor: 25, cap: 50,  color: '#2196F3' },
      { id: 'C', name: 'C Tier', floor: 0,  cap: 25,  color: '#9E9E9E' },
    ],
  },
});

await engine.connect();

// ✅ Batch ingest — normalizes across ALL teams, auto-assigns tiers
const result = await engine.batchIngest([
  {
    playerId: 'team-alpha',
    data: { placementPoints: 85, killPoints: 67, consistency: 92, matchesPlayed: 24 },
    metadata: { region: 'IN', clan: 'Wolves' },
    participantName: 'Team Alpha',
  },
  {
    playerId: 'team-bravo',
    data: { placementPoints: 72, killPoints: 88, consistency: 78, matchesPlayed: 22 },
    metadata: { region: 'IN', clan: 'Phoenix' },
    participantName: 'Team Bravo',
  },
  {
    playerId: 'team-charlie',
    data: { placementPoints: 45, killPoints: 31, consistency: 60, matchesPlayed: 10 },
    metadata: { region: 'EU' },
    participantName: 'Team Charlie',
  },
]);

console.log(result);
// → { processed: 3, tierDistribution: { S: 1, A: 1, C: 1 } }

// Query — auto-enriched with tier, breakdown, metadata
const page = await engine.getLeaderboard({ limit: 25 });
const sTier = await engine.getLeaderboard({ tier: 'S' });

// After config change — recalculate from stored raw data
const recalcResult = await engine.recalculate();

await engine.disconnect();

2. Manual Score (Low-Level)

For simple leaderboards where you compute the score yourself:

const engine = new LeaderboardEngine({ leaderboardName: 'bgmi-global-weekly', mongoUri: 'mongodb://localhost:27017/leaderboard', redisOptions: { host: '127.0.0.1', port: 6379 }, leaderboardType: 'global', // ← tier-based participantType: 'Team', // ← ranking teams });

await engine.connect();

// Add teams with tier data await engine.addPlayer('team-alpha', 92.5, { region: 'IN', clan: 'Wolves' }, { participantName: 'Team Alpha', tier: 'S', breakdown: { rps: 95.2, cr: 88.1, ke: 91.7, lb: 85.0 }, metrics: { kills: 342, damage: 128500, matchesPlayed: 24 }, tierScoreBand: { floor: 75, cap: 100 }, });

await engine.addPlayer('team-bravo', 71.3, { region: 'IN', clan: 'Phoenix' }, { participantName: 'Team Bravo', tier: 'A', breakdown: { rps: 72.1, cr: 68.4, ke: 74.9, lb: 70.0 }, metrics: { kills: 278, damage: 98200, matchesPlayed: 22 }, tierScoreBand: { floor: 50, cap: 75 }, });

// Get paginated leaderboard (enriched with all metadata from Mongo) const page = await engine.getLeaderboard({ limit: 25 }); console.log(page.items); // → [{ playerId: 'team-alpha', rank: 1, score: 92.5, tier: 'S', breakdown: {...}, ... }]

// Filter by tier const sTier = await engine.getLeaderboard({ tier: 'S', limit: 10 });

// Get rank for a specific team const rank = await engine.getRank('team-bravo'); // → { rank: 2, score: 71.3 }

// See who's around a specific team const window = await engine.getAroundPlayer('team-bravo', 5); // → { players: [...], playerRank: 2, windowSize: 5 }

await engine.disconnect();


### 2. Special Leaderboard (Single-Key)

A straightforward leaderboard sorted by one stat — no tiers, no normalization.

```typescript
const killsBoard = new LeaderboardEngine({
  leaderboardName: 'bgmi-kills-weekly',
  mongoUri: 'mongodb://localhost:27017/leaderboard',
  redisOptions: { host: '127.0.0.1', port: 6379 },
  leaderboardType: 'special',  // ← single-key, no tiers
  participantType: 'Team',
  sortKey: 'kills',            // ← displayed in metadata
});

await killsBoard.connect();

// Score IS the kills count
await killsBoard.addPlayer('team-alpha', 342, { region: 'IN' }, {
  participantName: 'Team Alpha',
  metrics: { kills: 342, damage: 128500 },
});

const top10 = await killsBoard.getTopN(10);

await killsBoard.disconnect();

3. Solo Player Leaderboard

For individual users instead of teams:

const soloBoard = new LeaderboardEngine({
  leaderboardName: 'quiz-highscores',
  mongoUri: 'mongodb://localhost:27017/leaderboard',
  redisOptions: { host: '127.0.0.1', port: 6379 },
  leaderboardType: 'special',
  participantType: 'User',     // ← solo players
  sortKey: 'totalScore',
});

await soloBoard.connect();

await soloBoard.addPlayer('user-123', 9850, { department: 'Engineering' }, {
  participantName: 'Alice',
});

await soloBoard.disconnect();

API Reference

new LeaderboardEngine(config)

Create a new engine instance. Does not connect — call .connect() separately.

See Configuration Reference for all options.

Lifecycle

MethodDescription
connect()Connect to Redis + MongoDB. Must call before any operations.
disconnect()Gracefully close all connections.

Score Management

MethodDescription
addPlayer(playerId, score, metadata?, options?)Add or update a participant
updateScore(playerId, score)Update score only
incrementScore(playerId, delta)Atomically increment score
removePlayer(playerId)Remove from leaderboard
bulkUpdate(players)Batch upsert (pipeline + bulkWrite)

Scoring Pipeline

MethodDescription
ingest(playerId, rawData, metadata?, options?)Single ingest (no cross-normalization)
batchIngest(entries)Batch ingest + cross-participant normalization
recalculate()Re-run pipeline on all stored rawData

Ranking Queries

MethodReturnsComplexity
getRank(playerId){ rank, score } or nullO(log N)
getTopN(limit?)RankedPlayer[]O(log N + K)
getLeaderboard(options?)PaginatedResult<RankedPlayer>O(log N + K)
getAroundPlayer(playerId, windowSize?)WindowResult or nullO(log N + 2W)

Maintenance

MethodDescription
rebuildFromMongo()Manually rebuild Redis from MongoDB
getTotalPlayers()Total participants (from Redis ZCARD)

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    Your Application                          │
│                                                             │
│   const engine = new LeaderboardEngine(config)              │
│   await engine.addPlayer('team-1', 92.5, metadata)         │
│   await engine.getLeaderboard({ limit: 25, tier: 'S' })    │
└──────────────────────────┬──────────────────────────────────┘
                           │
                ┌──────────▼──────────┐
                │  LeaderboardEngine  │  ← Public API facade
                │  (Orchestrator)     │
                └──────────┬──────────┘
                           │
          ┌────────────────┼──────────────────┐
          │                │                  │
   ┌──────▼──────┐  ┌─────▼──────┐  ┌───────▼───────┐
   │ SyncManager │  │  Ranking   │  │   Filter      │
   │             │  │  Service   │  │   Service     │
   │ write-thru  │  │ get/page   │  │ hybrid query  │
   └──────┬──────┘  └─────┬──────┘  └───────┬───────┘
          │               │                 │
   ┌──────▼──────┐  ┌─────▼──────┐  ┌───────▼───────┐
   │  RedisZSet  │  │   Cursor   │  │  MongoAdapter │
   │  (ioredis)  │  │ Paginator  │  │  (mongoose)   │
   └──────┬──────┘  └────────────┘  └───────┬───────┘
          │                                 │
   ┌──────▼──────┐                  ┌───────▼───────┐
   │    Redis    │                  │   MongoDB     │
   │ Sorted Sets │                  │  (truth)      │
   └─────────────┘                  └───────────────┘

Data Flow

Writes:

addPlayer(id, score, metadata)
    ├─→ Redis: ZADD key score playerId           (ranking)
    └─→ Mongo: findOneAndUpdate with upsert      (persistence)

Reads:

getLeaderboard({ limit: 25 })
    ├─→ Redis: ZREVRANGE key start stop WITHSCORES  (fast ranking)
    └─→ Mongo: find({ playerId: { $in: ids } })     (metadata enrich)

Crash Recovery:

Redis key missing?
    └─→ Mongo: find all → Redis: pipeline ZADD all  (auto-rebuild)

Pagination Strategy

Cursor-Based (Not Skip/Limit)

We use rank-based cursor pagination. The cursor encodes the current position (0-indexed rank) in the sorted set.

ApproachComplexityWhy?
✅ Cursor (rank-based)O(log N + K)Stable, efficient at any depth
❌ Skip/limitO(skip + limit)Page 10,000 scans 10,000 entries first
❌ Score-based cursorO(log N + K)Breaks on duplicate scores
// First page
const page1 = await engine.getLeaderboard({ limit: 25 });
console.log(page1.nextCursor); // "eyJyYW5rIjoyNS..."

// Next page
const page2 = await engine.getLeaderboard({ cursor: page1.nextCursor, limit: 25 });

// Previous page
const prev = await engine.getLeaderboard({ cursor: page2.prevCursor, limit: 25 });

Tradeoffs

ProCon
O(log N + K) at any page depthCursor invalidates if rankings shift between requests
No full-scan everNo random page access (must traverse sequentially)
Works with Redis ZRANGE nativelyConcurrent writes may shift a player across page boundaries

Filtering

Two Strategies

1. Dedicated Sub-Leaderboards (recommended for known dimensions)

// Create separate leaderboard instances per region
const euBoard = new LeaderboardEngine({ leaderboardName: 'bgmi-global-EU', ... });
const naBoard = new LeaderboardEngine({ leaderboardName: 'bgmi-global-NA', ... });
  • ✅ O(log N) per query
  • ✅ Full cursor pagination
  • ❌ More Redis keys to manage

2. Hybrid Redis+Mongo Filtering (for ad-hoc queries)

// Filter by metadata at query time
const euPlayers = await engine.getLeaderboard({
  filters: { region: 'EU' },
  limit: 25,
});

// Filter by tier
const sTier = await engine.getLeaderboard({ tier: 'S', limit: 25 });
  • ✅ No extra Redis keys
  • ✅ Works with any metadata field
  • ❌ O(M log N) where M = matching players
  • ❌ Warns if M > 10,000

Crash Recovery

If Redis loses data (restart, eviction, crash), the engine automatically rebuilds from MongoDB on the next read:

1. getLeaderboard() called
2. Check: does Redis key exist? → NO
3. Stream all players from Mongo (sorted by score desc)
4. Pipeline ZADD all into Redis
5. Return results normally

Disable with autoRebuild: false in config. Trigger manually with engine.rebuildFromMongo().

Concurrency Safety

OperationSafety Mechanism
Score updatesRedis ZADD is atomic; Mongo findOneAndUpdate is atomic
Score incrementsRedis ZINCRBY is atomic; Mongo $inc is atomic
Bulk writesMongo bulkWrite({ ordered: false }) — independent writes
No .save() callsAvoids Mongoose version key (__v) conflicts

No distributed locks needed. Each atomic operation on its own guarantees consistency.

Configuration Reference

interface LeaderboardConfig {
  // Required
  leaderboardName: string;           // Unique identifier (Redis key + Mongo partition)
  mongoUri: string;                  // MongoDB connection string
  redisOptions: RedisOptions;        // ioredis connection options

  // Mode
  leaderboardType?: 'global' | 'special';  // Default: 'global'
  participantType?: 'Team' | 'User';       // Default: 'Team'
  sortKey?: string;                        // Required for 'special' leaderboards

  // Pagination
  defaultPageSize?: number;          // Default: 25
  maxPageSize?: number;              // Default: 100

  // Hooks
  logger?: LoggerHook;               // Plug in winston, pino, etc.
  metrics?: MetricsHook;             // Plug in StatsD, Prometheus, etc.

  // Resilience
  retryStrategy?: {
    maxRetries: number;              // Default: 3
    baseDelayMs: number;             // Default: 200 (exponential backoff)
  };
  autoRebuild?: boolean;             // Default: true
}

Logger Hook

const engine = new LeaderboardEngine({
  // ...
  logger: {
    debug: (msg, meta) => console.debug(msg, meta),
    info:  (msg, meta) => console.info(msg, meta),
    warn:  (msg, meta) => console.warn(msg, meta),
    error: (msg, meta) => console.error(msg, meta),
  },
});

Metrics Hook

const engine = new LeaderboardEngine({
  // ...
  metrics: {
    increment: (metric, value, tags) => statsd.increment(metric, value, tags),
    timing:    (metric, ms, tags)    => statsd.timing(metric, ms, tags),
  },
});

Performance Characteristics

OperationRedis ComplexityNetwork RTTs
addPlayerO(log N)1 Redis + 1 Mongo
getRankO(log N)1 Redis
getTopN(K)O(log N + K)1 Redis + 1 Mongo (enrich)
getLeaderboard(K)O(log N + K)1 Redis + 1 Mongo (enrich)
getAroundPlayer(W)O(log N + 2W)2 Redis + 1 Mongo (enrich)
incrementScoreO(log N)1 Redis + 1 Mongo
bulkUpdate(M)O(M log N)1 Redis pipeline + 1 Mongo bulkWrite
Filtered query (M matches)O(M log N)1 Mongo + M Redis pipeline

N = total participants, K = page size, W = window size, M = matching players

Scaling Notes

Vertical Scaling (Single Redis)

A single Redis instance handles ~100K concurrent ZSET operations/sec. For most leaderboards (< 1M entries), a single Redis is sufficient.

Horizontal Scaling

StrategyWhen
Redis Cluster> 10M entries per leaderboard
Read replicasHeavy read traffic (> 50K reads/sec)
Sharded leaderboardsPartition by region, game, time period
MongoDB replica setStandard — use for all production deployments
┌─────────────┐     ┌───────────────────┐     ┌──────────────┐
│  App Server  │────▶│  Redis (primary)  │     │  MongoDB RS  │
│  App Server  │────▶│  Redis (replica)  │────▶│  (3 nodes)   │
│  App Server  │────▶│                   │     │              │
└─────────────┘     └───────────────────┘     └──────────────┘

Roadmap

[!NOTE] This project is open source and in active beta development.

  • Unit & integration tests — Jest test suite with Redis/Mongo mocks
  • TTL-based leaderboards — auto-expire entries after X days
  • Leaderboard snapshots — weekly/daily point-in-time snapshots
  • WebSocket real-time updates — push rank changes to clients
  • Redis Cluster support — hash tags for multi-key operations
  • Rate limiting extension — configurable write throttling
  • CLI tool — inspect, rebuild, and manage leaderboards from terminal
  • Dashboard UI — admin panel for leaderboard management
  • Published to npmnpm install leaderboard-engine

Contributing

We welcome contributions! This project is in beta, so there's a lot of room to shape the API and architecture.

How to Contribute

  • Fork the repository
  • Create a branch for your feature: git checkout -b feat/my-feature
  • Make your changes with tests
  • Run the build: npm run build
  • Submit a PR with a clear description

Areas Where Help Is Needed

  • 🧪 Test coverage — Unit tests, integration tests, load tests
  • 📝 Documentation — More examples, edge cases, migration guides
  • 🔌 Integrations — Express/Fastify middleware, Socket.io adapter
  • 🎯 Performance — Benchmarks, profiling, optimization
  • 🐛 Bug reports — File issues with reproduction steps

License

MIT — see LICENSE for details.

Built with ❤️ for competitive gaming and beyond.
Redis for speed. MongoDB for truth. TypeScript for safety.

Keywords

leaderboard

FAQs

Package last updated on 22 Feb 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts