Redisboard.NET is an optimized .NET library designed to handle leaderboards efficiently using Redis. It provides a simple, yet powerful API to create and manage leaderboards with various ranking systems. The library leverages Redis sorted sets for performance and uses LUA scripts for advanced querying capabilities.
Want to understand how it works under the hood? Read the Architecture Deep Dive — covering the three-structure Redis design, score negation, Lua atomicity, ranking algorithms, serialization, and common pitfalls. A good starting point whether you're evaluating the library or contributing to it.
First up, let's get a Redis server ready. Docker makes this super easy.
After you've got Docker set up on your machine, just pop open your terminal and run:
docker run --name my-redis-server -d redisWith your Redis server humming along, it's time to install the Redisboard.NET package with this quick command:
dotnet add package Redisboard.NETDefine your leaderboard entity. It must:
- Implement
ILeaderboardEntity - Be
partialand annotated with[MemoryPackable] - Decorate exactly one property with
[LeaderboardKey] - Decorate exactly one property with
[LeaderboardScore]
// Define a player class implementing ILeaderboardEntity
[MemoryPackable]
public partial class Player : ILeaderboardEntity
{
[LeaderboardKey]
public string Id { get; set; }
[LeaderboardScore]
public double Score { get; set; }
public long Rank { get; set; } // Populated automatically on reads
// Additional properties are stored as metadata
public string Username { get; set; }
public string AvatarUrl { get; set; }
}Start managing your leaderboard with the Leaderboard<Player> class:
const string leaderboardKey = "your_leaderboard_key"
// Initialize a Leaderboard by passing IDatabase or IConnectionMultiplexer
var leaderboard = new Leaderboard<Player>(redis, new MemoryPackLeaderboardSerializer());
var players = new[]
{
new Player { Id = "player1", Score = 100, Username = "Alice" },
new Player { Id = "player2", Score = 150, Username = "Bob" }
};
// Add players to the leaderboard
await leaderboard.AddEntityAsync(leaderboardKey, players[0]);
await leaderboard.AddEntityAsync(leaderboardKey, players[1]);
// Retrieve player and neighbors (offset is default 10)
var result = await leaderboard.GetEntityAndNeighboursAsync(
leaderboardKey, "player1", RankingType.Default);Example result:
Rank Player Points
1 Bob 150
2 Maya 125
3 -> player1 100
4 Sam 90
5 Nina 80
Register the Leaderboard in your IServiceCollection using the built-in extension method:
AddLeaderboard also registers an IConnectionMultiplexer internally when you provide a config delegate and no existing Redis services are already registered. The cfg object is StackExchange.Redis ConfigurationOptions, so any supported connection settings can be configured there.
// Add to IServiceCollection
builder.Services.AddLeaderboard<Player>(cfg =>
{
cfg.EndPoints.Add("localhost:6379");
cfg.ClientName = "Development";
cfg.DefaultDatabase = 0;
});* Config delegate is not required if you have already registered your IConnectionMultiplexer or IDatabase (ref. StackExchange.Redis). AddLeaderboard prefers IConnectionMultiplexer when both are registered. If it falls back to IDatabase, the databaseIndex argument is ignored because the database has already been selected.
See StackExchange.Redis configuration docs for more ConfigurationOptions settings: Basics and Configuration.
Once registered, inject ILeaderboard<Player> via the constructor:
public class MyService
{
private readonly ILeaderboard<Player> _leaderboard;
public MyService(ILeaderboard<Player> leaderboard)
{
_leaderboard = leaderboard;
}
public async Task AddPlayersAsync(Player[] players)
{
const string leaderboardKey = "your_leaderboard_key"
foreach (var player in players)
await _leaderboard.AddEntityAsync(leaderboardKey, player);
}
}Other common APIs:
// Add many players in one batch operation.
await leaderboard.AddEntitiesAsync(leaderboardKey, players);
// Update only score for an existing player with model
await leaderboard.UpdateEntityScoreAsync(
leaderboardKey,
new Player { Id = "player1", Score = 175 });
// Update only score for an existing player by key and score
await leaderboard.UpdateEntityScoreAsync(
leaderboardKey, entityKey: "player1", score: 175);;
// Update metadata without changing rank or score.
await leaderboard.UpdateEntityMetadataAsync(
leaderboardKey,
new Player { Id = "player1", Username = "Alice", AvatarUrl = "avatar.png" });
// Get players between rank 1 and rank 10.
var topTen = await leaderboard.GetEntitiesByRankRangeAsync(
leaderboardKey, 1, 10, RankingType.Default);
// Get players whose scores fall within an inclusive range.
var scoreRange = await leaderboard.GetEntitiesByScoreRangeAsync(
leaderboardKey, 80, 150, RankingType.Default);
// Get single player's current score.
var playerScore = await leaderboard.GetEntityScoreAsync(leaderboardKey, "player1");
// Get single player's current rank.
var playerRank = await leaderboard.GetEntityRankAsync(
leaderboardKey, "player1", RankingType.Default);
// Get total number of players in leaderboard.
var totalPlayers = await leaderboard.GetSizeAsync(leaderboardKey);
// Delete one player from leaderboard.
await leaderboard.DeleteEntityAsync(leaderboardKey, "player1");
// Delete many players in one batch.
await leaderboard.DeleteEntitiesAsync(leaderboardKey, ["player1", "player2"]);
// Delete entire leaderboard and all associated Redis data.
await leaderboard.DeleteAsync(leaderboardKey);The library uses MemoryPack by default for high-performance serialization. All entity types must be properly configured:
[MemoryPackable] // Required: enables MemoryPack source generation
public partial class Player : ILeaderboardEntity
{
[LeaderboardKey] // Identifies the entity uniquely
public string Id { get; set; }
[LeaderboardScore] // Used for ranking
public double Score { get; set; }
public long Rank { get; set; } // Auto-populated on reads
}Key requirements:
- The class must be
partial— the source generator emits a companion file - Every nested type needs
[MemoryPackable]— ifPlayerhas aStatsproperty,Statsmust also be[MemoryPackable] partial - Parameterless constructor — MemoryPack needs either a parameterless constructor or a constructor whose parameter names match property names
- Inheritance requires explicit registration — for polymorphic hierarchies:
[MemoryPackable]
[MemoryPackUnion(0, typeof(HumanPlayer))]
[MemoryPackUnion(1, typeof(BotPlayer))]
public abstract partial class Player { }
[MemoryPackable]
public partial class HumanPlayer : Player { }| Attribute | Supported Types |
|---|---|
[LeaderboardKey] |
string, Guid, int, long, RedisValue |
[LeaderboardScore] |
double, float, int, long |
If you cannot use MemoryPack, implement ILeaderboardSerializer and pass it to the Leaderboard constructor:
public class SystemTextJsonSerializer : ILeaderboardSerializer
{
public byte[] Serialize<T>(T value)
=> JsonSerializer.SerializeToUtf8Bytes(value);
public T Deserialize<T>(byte[] data)
=> JsonSerializer.Deserialize<T>(data)!;
}
builder.Services.AddLeaderboard<Player>(cfg => { /* ... */ }, new SystemTextJsonSerializer());
// Or via manually created object
var leaderboard = new Leaderboard<Player>(redis, new SystemTextJsonSerializer());This repository also includes a very simple API project, which shows how you can setup the Leaderboard. You can find the project here
Ranks are calculated when you query data. Redisboard.NET stores leaderboard scores in a Redis Sorted Set and entity metadata in a Redis Hash. It then uses Lua scripts to read both efficiently and apply the selected ranking rules.
This design keeps writes simple and makes reads fast. In our benchmarks, querying a leaderboard with more than 500,000 players stays under 1 ms for the common read paths shown below.
Every read API that returns ranks requires a RankingType. That value controls how ties are handled.
Players are ordered by score first. If two or more players have the same score, Redis uses the member key as a tie-breaker and orders those tied players lexicographically. Each player still gets a unique rank number, so no rank numbers are shared or skipped.
This is the default ordering behavior of a Redis Sorted Set.
Example:
Scores: [{John, 100}, {Micah, 100}, {Alex, 99}, {Tim, 1}]
Ranks: [1, 2, 3, 4]
2. Dense Rank 🥇🥈
Players with the same score receive the same rank. The next distinct score receives the next rank number with no gaps.
Example:
Scores: [100, 80, 50, 50, 40, 10]
Ranks: [1, 2, 3, 3, 4, 5]
3. Standard Competition 🏅
Players with the same score receive the same rank. The next distinct score skips ahead by the number of tied players.
Example:
Scores: [100, 80, 50, 50, 40, 10]
Ranks: [1, 2, 3, 3, 5, 6]
4. Modified Competition 🎖️
Players with the same score receive the same rank, but the gap appears before the tied group instead of after it.
Example:
Scores: [100, 80, 50, 50, 40, 10]
Ranks: [1, 2, 4, 4, 5, 6]
Use RankingType.Default when you want Redis native ordering and a unique position for every player. Use one of the other ranking types when tied scores should share the same displayed rank.
These benchmarks were run over a leaderboard with 500,000 entries of type:
public class Player : ILeaderboardEntity
{
public string Key { get; set; }
public long Rank { get; set; }
public double Score { get; set; }
public string Username { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime EntryDate { get; set; }
}We benchmarked the most common read path: getting an entity and its neighbors with a relevant offset.*
*offset controls how many neighbors above and below the target entity are returned.
Environment: BenchmarkDotNet 0.15.8, .NET 10.0.5, Apple M3 (8 cores), macOS Tahoe 26.3.1.
What these benchmarks show:
- Querying a 500K leaderboard stays sub-millisecond across all tested ranking modes and offsets.
- Default ranking is the fastest baseline in every scenario.
- Dense and Competition ranking add ranking semantics with a moderate overhead, while still staying very fast.
- Memory usage scales predictably with
offset(more neighbors returned = more allocation).
| Method | Offset | Mean | Max | Allocated |
|---|---|---|---|---|
| Default Ranking | 10 | 449.6 us | 509.9 us | 26.1 KB |
| Dense Ranking | 10 | 489.1 us | 537.9 us | 27.57 KB |
| Competition Ranking | 10 | 515.0 us | 665.5 us | 27.51 KB |
| Default Ranking | 20 | 473.6 us | 570.9 us | 50.25 KB |
| Dense Ranking | 20 | 518.7 us | 556.1 us | 50.99 KB |
| Competition Ranking | 20 | 526.8 us | 567.5 us | 50.86 KB |
| Default Ranking | 50 | 522.7 us | 566.4 us | 118.48 KB |
| Dense Ranking | 50 | 664.5 us | 706.3 us | 120.88 KB |
| Competition Ranking | 50 | 773.8 us | 899.0 us | 121.55 KB |