Skip to content

svetstoykov/Redisboard.NET

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

90 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Redisboard.NET 🚀

CI NuGet License

A high-performance .NET Library for creating and interacting with Leaderboards using Redis.

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.

Quick Start

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 redis

With your Redis server humming along, it's time to install the Redisboard.NET package with this quick command:

dotnet add package Redisboard.NET

Define your leaderboard entity. It must:

  • Implement ILeaderboardEntity
  • Be partial and 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

Dependency Injection

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);

Entity Requirements

MemoryPack Serialization

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:

  1. The class must be partial — the source generator emits a companion file
  2. Every nested type needs [MemoryPackable] — if Player has a Stats property, Stats must also be [MemoryPackable] partial
  3. Parameterless constructor — MemoryPack needs either a parameterless constructor or a constructor whose parameter names match property names
  4. 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 { }

Supported Property Types

Attribute Supported Types
[LeaderboardKey] string, Guid, int, long, RedisValue
[LeaderboardScore] double, float, int, long

Custom Serializer

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());

DemoAPI

This repository also includes a very simple API project, which shows how you can setup the Leaderboard. You can find the project here

Ranking

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.

1. Default Ranking 🏆

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]

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]

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.

Benchmarks 🚀

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.

Latest run at a glance

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

Dependencies

About

A high-performance .NET Library for creating and interacting with Leaderboards using Redis

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors