
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
leaderboard-engine
Advanced tools
Production-grade leaderboard engine using MongoDB (truth layer) + Redis ZSET (ranking engine). Cursor pagination, window queries, filtering, multi-leaderboard support.
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!
A TypeScript npm package that gives you a production-ready leaderboard out of the box. Under the hood:
| Layer | Technology | Role |
|---|---|---|
| Ranking | Redis Sorted Sets (ZSET) | O(log N) rank lookups, O(log N + K) range queries |
| Persistence | MongoDB | Truth layer, metadata, filtering, crash recovery |
| Application | TypeScript | Sync, 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.
| Feature | Description |
|---|---|
| ✅ Scoring Pipeline | Config-driven: map raw data fields → weighted score |
| ✅ Auto Tier Assignment | Score falls in band → tier assigned automatically |
| ✅ Band Normalization | Scores capped within tier [floor, cap] ranges |
| ✅ Batch Normalization | Cross-participant min-max normalization |
| ✅ Recalculate | Re-run pipeline on stored rawData (after config change) |
| ✅ Add / Update / Remove | Full CRUD for participants with atomic operations |
| ✅ Increment Scores | Atomic ZINCRBY + Mongo $inc — safe under concurrency |
| ✅ Bulk Updates | Pipeline + bulkWrite for high-throughput batch operations |
| ✅ Get Rank | O(log N) single-player rank lookup |
| ✅ Top N | Get the first N entries |
| ✅ Paginated Leaderboard | Cursor-based pagination (no skip/limit!) |
| ✅ Around Player | Window query — "show me 10 above and 10 below this player" |
| ✅ Hybrid Filtering | Filter by metadata (region, clan, etc.) via Redis+Mongo |
| ✅ Tier Filtering | Filter by tier (S, A, B, C) |
| ✅ Team & Solo | `participantType: 'Team' |
| ✅ Tier System | S/A/B/C score-band tiers for global leaderboards |
| ✅ Score Breakdown | RPS, CR, KE, LB component scores with normalization |
| ✅ Crash Recovery | Auto-rebuild Redis from MongoDB if key is missing |
| ✅ Metrics Hooks | Plug in your own metrics collector |
| ✅ Logger Hooks | Plug in winston, pino, console — anything |
| Anti-Pattern | Why We Avoid It |
|---|---|
| ❌ Skip/limit pagination | O(N) scan — unusable at scale |
| ❌ MongoDB-only ranking | No ZSET = no O(log N) rank lookups |
| ❌ Heavy read aggregations | Aggregation pipelines are slow for real-time reads |
| ❌ Synchronous blocking | All I/O is async/await |
| ❌ Score computation | This module ranks pre-computed scores — computation is your responsibility |
This module supports any leaderboard that can be sorted by a single numeric key. Here are concrete examples:
| Leaderboard | Sort Key | Participant Type |
|---|---|---|
| Global Tier Rankings (BGMI, FreeFire, CODM) | tierScore (composite 0-100) | Team |
| Kill Leaderboard | kills | Team or User |
| Damage Leaderboard | damage | User |
| Win Streak | winStreak | Team |
| Weekly Tournament Rankings | tournamentPoints | Team |
| Leaderboard | Sort Key | Participant Type |
|---|---|---|
| Steps Challenge | totalSteps | User |
| Calories Burned | caloriesBurned | User |
| Workout Streak | streakDays | User |
| Leaderboard | Sort Key | Participant Type |
|---|---|---|
| Sales Leaderboard | revenue | User |
| Support Tickets Resolved | ticketsResolved | User |
| Team Sprint Points | sprintPoints | Team |
| Customer Referrals | referralCount | User |
| Leaderboard | Sort Key | Participant Type |
|---|---|---|
| Quiz Scores | totalScore | User |
| Course Completions | coursesCompleted | User |
| Coding Challenges | problemsSolved | User |
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.
| Requirement | Version |
|---|---|
| Node.js | >= 16.x |
| Redis | >= 6.x |
| MongoDB | >= 5.x |
| TypeScript | >= 4.7 (optional, JS works too) |
npm install leaderboard-engine
[!NOTE] This package is published on npm and in beta. Report issues on GitHub.
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();
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();
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();
new LeaderboardEngine(config)Create a new engine instance. Does not connect — call .connect() separately.
See Configuration Reference for all options.
| Method | Description |
|---|---|
connect() | Connect to Redis + MongoDB. Must call before any operations. |
disconnect() | Gracefully close all connections. |
| Method | Description |
|---|---|
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) |
| Method | Description |
|---|---|
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 |
| Method | Returns | Complexity |
|---|---|---|
getRank(playerId) | { rank, score } or null | O(log N) |
getTopN(limit?) | RankedPlayer[] | O(log N + K) |
getLeaderboard(options?) | PaginatedResult<RankedPlayer> | O(log N + K) |
getAroundPlayer(playerId, windowSize?) | WindowResult or null | O(log N + 2W) |
| Method | Description |
|---|---|
rebuildFromMongo() | Manually rebuild Redis from MongoDB |
getTotalPlayers() | Total participants (from Redis ZCARD) |
┌─────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────┘ └───────────────┘
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)
We use rank-based cursor pagination. The cursor encodes the current position (0-indexed rank) in the sorted set.
| Approach | Complexity | Why? |
|---|---|---|
| ✅ Cursor (rank-based) | O(log N + K) | Stable, efficient at any depth |
| ❌ Skip/limit | O(skip + limit) | Page 10,000 scans 10,000 entries first |
| ❌ Score-based cursor | O(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 });
| Pro | Con |
|---|---|
| O(log N + K) at any page depth | Cursor invalidates if rankings shift between requests |
| No full-scan ever | No random page access (must traverse sequentially) |
| Works with Redis ZRANGE natively | Concurrent writes may shift a player across page boundaries |
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', ... });
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 });
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().
| Operation | Safety Mechanism |
|---|---|
| Score updates | Redis ZADD is atomic; Mongo findOneAndUpdate is atomic |
| Score increments | Redis ZINCRBY is atomic; Mongo $inc is atomic |
| Bulk writes | Mongo bulkWrite({ ordered: false }) — independent writes |
No .save() calls | Avoids Mongoose version key (__v) conflicts |
No distributed locks needed. Each atomic operation on its own guarantees consistency.
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
}
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),
},
});
const engine = new LeaderboardEngine({
// ...
metrics: {
increment: (metric, value, tags) => statsd.increment(metric, value, tags),
timing: (metric, ms, tags) => statsd.timing(metric, ms, tags),
},
});
| Operation | Redis Complexity | Network RTTs |
|---|---|---|
addPlayer | O(log N) | 1 Redis + 1 Mongo |
getRank | O(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) |
incrementScore | O(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
A single Redis instance handles ~100K concurrent ZSET operations/sec. For most leaderboards (< 1M entries), a single Redis is sufficient.
| Strategy | When |
|---|---|
| Redis Cluster | > 10M entries per leaderboard |
| Read replicas | Heavy read traffic (> 50K reads/sec) |
| Sharded leaderboards | Partition by region, game, time period |
| MongoDB replica set | Standard — use for all production deployments |
┌─────────────┐ ┌───────────────────┐ ┌──────────────┐
│ App Server │────▶│ Redis (primary) │ │ MongoDB RS │
│ App Server │────▶│ Redis (replica) │────▶│ (3 nodes) │
│ App Server │────▶│ │ │ │
└─────────────┘ └───────────────────┘ └──────────────┘
[!NOTE] This project is open source and in active beta development.
npm install leaderboard-engine ✅We welcome contributions! This project is in beta, so there's a lot of room to shape the API and architecture.
git checkout -b feat/my-featurenpm run buildMIT — see LICENSE for details.
Built with ❤️ for competitive gaming and beyond.
Redis for speed. MongoDB for truth. TypeScript for safety.
FAQs
Production-grade leaderboard engine using MongoDB (truth layer) + Redis ZSET (ranking engine). Cursor pagination, window queries, filtering, multi-leaderboard support.
We found that leaderboard-engine demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

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.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.