Elo Rating System (Worked Example)

A full feature built from the pieces documented elsewhere on this site: a pure calculation class with no dependencies, an in-memory leaderboard cache, and an orchestrating service that ties them to the repositories — individual and team matches, from the same rating math.

The pure calculation core

Every formula lives as a static method with no I/O and no dependencies — this is exactly the kind of class Unit Testing with Moq covers with [Theory] instead of mocks, because there's nothing to mock.

EloCalculationService.cs
public class EloCalculationService
{
    public const double DefaultMaxRating = 2000;
    public const double MinRatingFloor = 100;
    public const double InitialRating = 800;

    // new players move fast (K=40), veterans move slow (K=10) — the rating
    // settles down as it becomes more representative of real skill
    public static double GetKFactor(int matchCount) => matchCount switch
    {
        < 10 => 40,
        < 30 => 20,
        _ => 10
    };

    // the standard logistic Elo curve: a 400-point gap means the
    // higher-rated player is expected to win ~91% of the time
    public static double CalculateExpectedScore(double playerRating, double opponentRating)
        => 1.0 / (1.0 + Math.Pow(10, (opponentRating - playerRating) / 400.0));

    public static double CalculateNewRating(
        double currentRating, double actualScore, double expectedScore,
        int matchCount, double maxRating = DefaultMaxRating)
    {
        double kFactor = GetKFactor(matchCount);
        double newRating = currentRating + kFactor * (actualScore - expectedScore);
        return Math.Clamp(newRating, MinRatingFloor, maxRating);
    }

    // placement in an N-player match, linearly interpolated to a 0..1 score —
    // 1v1 is a special case since interpolating with 2 players is just win/loss
    public static double ConvertPlacementToScore(int placement, int totalParticipants, bool isTie = false)
    {
        if (isTie) return 0.5;
        if (totalParticipants <= 2) return placement == 1 ? 1.0 : 0.0;

        return Math.Max(0.0, 1.0 - ((placement - 1.0) / (totalParticipants - 1.0)));
    }

    public static bool IsValidActualScore(double actualScore)
        => actualScore >= 0.0 && actualScore <= 1.0;
}

Team matches reuse every one of these — a team's "rating" for matchmaking purposes is just the average of its members, fed through the same expected-score formula:

EloCalculationService.cs — team variants
public static double CalculateTeamAverageRating(List<double> teamMemberRatings)
    => teamMemberRatings is { Count: > 0 } ? teamMemberRatings.Average() : InitialRating;

public static double CalculateTeamExpectedScore(List<double> teamRatings, List<double> opponentTeamRatings)
    => CalculateExpectedScore(
        CalculateTeamAverageRating(teamRatings),
        CalculateTeamAverageRating(opponentTeamRatings));

// every team member gets the SAME team actual/expected score, but their OWN
// K-factor and current rating — a veteran on a winning team moves less than
// a newcomer on the same team
public static List<double> CalculateTeamNewRatings(
    List<double> teamMemberRatings, double teamActualScore, double teamExpectedScore,
    List<int> matchCounts, double maxRating = DefaultMaxRating)
{
    if (teamMemberRatings.Count != matchCounts.Count)
        throw new ArgumentException("Ratings and match counts must be the same length.");

    return teamMemberRatings
        .Select((rating, i) => CalculateNewRating(rating, teamActualScore, teamExpectedScore, matchCounts[i], maxRating))
        .ToList();
}

Caching the leaderboard, per category and overall

Same locked-dictionary-with-TTL shape as In-Memory Caching, extended to hold more than one cache entry — one per category, plus a special Guid.Empty key for the cross-category overall leaderboard. The overall leaderboard also has to combine ratings across categories, which the per-category one doesn't.

LeaderboardService.cs
public class LeaderboardService
{
    private readonly Dictionary<Guid, LeaderboardCache> _cache = [];
    private readonly object _lock = new();

    public List<LeaderboardEntry> GetCategoryLeaderboard(
        Guid categoryId, IEnumerable<Rating> ratings, int cacheDurationMinutes = 5)
    {
        lock (_lock)
        {
            if (_cache.TryGetValue(categoryId, out var cached) &&
                DateTime.UtcNow - cached.CachedAt < TimeSpan.FromMinutes(cacheDurationMinutes))
                return cached.Entries;

            var entries = BuildLeaderboardEntries(ratings);
            _cache[categoryId] = new LeaderboardCache { CachedAt = DateTime.UtcNow, Entries = entries };
            return entries;
        }
    }

    public List<LeaderboardEntry> GetOverallLeaderboard(IEnumerable<Rating> allRatings, int cacheDurationMinutes = 5)
    {
        var overallKey = Guid.Empty; // a sentinel key sharing the same cache dictionary

        lock (_lock)
        {
            if (_cache.TryGetValue(overallKey, out var cached) &&
                DateTime.UtcNow - cached.CachedAt < TimeSpan.FromMinutes(cacheDurationMinutes))
                return cached.Entries;

            // group by user first — one player's ratings across every category
            // collapse into a single weighted overall rating (see CalculateOverallRating)
            var entries = allRatings
                .Where(r => r.User != null)
                .GroupBy(r => r.UserId)
                .Select(group => (group.First().User!, CategoryRatings: group.ToDictionary(
                    r => r.CategoryId.ToString(), r => (r.CurrentRating, r.MatchCount))))
                .Select(data => new LeaderboardEntry
                {
                    UserId = data.User.Id,
                    FullName = data.User.FullName,
                    Rating = EloCalculationService.CalculateOverallRating(data.CategoryRatings),
                    MatchCount = data.CategoryRatings.Values.Sum(x => x.MatchCount)
                })
                .OrderByDescending(e => e.Rating)
                .Select((entry, index) => { entry.Rank = index + 1; return entry; })
                .ToList();

            _cache[overallKey] = new LeaderboardCache { CachedAt = DateTime.UtcNow, Entries = entries };
            return entries;
        }
    }

    // a recorded match invalidates BOTH: the category it happened in,
    // and the overall leaderboard it also feeds into
    public void InvalidateCache(Guid categoryId)
    {
        lock (_lock)
        {
            _cache.Remove(categoryId);
            _cache.Remove(Guid.Empty);
        }
    }
}

The orchestrating service

This is where the repositories (Repository Pattern), the calculation class, and the cache all meet. The interesting part is that individual and team matches reuse almost all the same per-player update logic — only how actual score and expected score are computed up front differs.

EloService.cs — recording an individual match
public async Task<MatchResultDto> RecordMatchAsync(RecordMatchRequest request)
{
    var category = await _categoryRepository.GetByIdAsync(request.CategoryId)
        ?? throw new InvalidOperationException($"Category {request.CategoryId} not found");

    var match = new Match { Id = Guid.NewGuid(), CategoryId = request.CategoryId, IsFinalized = false };

    var results = request.MatchType?.ToLower() == "team"
        ? await ProcessTeamMatchAsync(match, request)
        : await ProcessIndividualMatchAsync(match, request);

    match.IsFinalized = true;
    await _matchRepository.UpdateAsync(match);

    // the cache doesn't know a match happened — it has to be told explicitly
    _leaderboardService.InvalidateCache(request.CategoryId);

    return new MatchResultDto { MatchId = match.Id, Results = results };
}

private async Task<List<ParticipantResultDto>> ProcessIndividualMatchAsync(Match match, RecordMatchRequest request)
{
    // load every participant's current rating up front — an N-player free-for-all
    // needs everyone else's rating to compute any one player's expected score
    var participantData = new List<(MatchParticipantInput Input, Rating Rating, User User)>();
    foreach (var p in request.Participants)
    {
        var rating = await _ratingRepository.GetOrCreateRatingAsync(p.UserId, match.CategoryId);
        var user = await _userRepository.GetByIdAsync(p.UserId)
            ?? throw new InvalidOperationException($"User {p.UserId} not found");
        participantData.Add((p, rating, user));
    }

    var results = new List<ParticipantResultDto>();
    var totalPlayers = request.Participants.Count;

    for (int i = 0; i < participantData.Count; i++)
    {
        var (input, rating, user) = participantData[i];

        var actualScore = EloCalculationService.ConvertPlacementToScore(input.Placement, totalPlayers, input.IsTie);

        // expected score against a free-for-all field = average expected score
        // against every OTHER participant, one at a time
        var otherRatings = participantData.Where((_, idx) => idx != i).Select(p => p.Rating.CurrentRating).ToList();
        var expectedScore = otherRatings.Count == 0
            ? 0.5
            : otherRatings.Average(opp => EloCalculationService.CalculateExpectedScore(rating.CurrentRating, opp));

        var newRating = EloCalculationService.CalculateNewRating(rating.CurrentRating, actualScore, expectedScore, rating.MatchCount);
        var ratingChange = newRating - rating.CurrentRating;

        rating.CurrentRating = newRating;
        rating.MatchCount++;
        if (newRating > rating.PeakRating) rating.PeakRating = newRating;
        await _ratingRepository.UpdateAsync(rating);

        await _participantRepository.AddAsync(new MatchParticipant
        {
            MatchId = match.Id, UserId = user.Id, Placement = input.Placement,
            ActualScore = actualScore, ExpectedScore = expectedScore, RatingChange = ratingChange
        });

        results.Add(new ParticipantResultDto { UserId = user.Id, RatingChange = ratingChange, NewRating = newRating });
    }

    return results;
}
EloService.cs — team matches: group, then reuse the same per-player update
private async Task<List<ParticipantResultDto>> ProcessTeamMatchAsync(Match match, RecordMatchRequest request)
{
    var teamGroups = request.Participants.GroupBy(p => p.TeamId ?? Guid.NewGuid()).ToList();
    if (teamGroups.Count < 2)
        throw new InvalidOperationException("A team match needs at least 2 teams.");

    // ... load each team's members' ratings the same way as the individual path ...

    var team1Ratings = team1Data.Members.Select(m => m.Rating.CurrentRating).ToList();
    var team2Ratings = team2Data.Members.Select(m => m.Rating.CurrentRating).ToList();

    double team1Expected = EloCalculationService.CalculateTeamExpectedScore(team1Ratings, team2Ratings);
    double team1Actual = team1Data.Placement == team2Data.Placement ? 0.5 : (team1Data.Placement == 1 ? 1.0 : 0.0);

    // one shared team score, but CalculateTeamNewRatings still applies each
    // member's OWN K-factor — the "reuse the per-player math" part
    var team1MatchCounts = team1Data.Members.Select(m => m.Rating.MatchCount).ToList();
    var team1NewRatings = EloCalculationService.CalculateTeamNewRatings(team1Ratings, team1Actual, team1Expected, team1MatchCounts);

    // ... persist each member's new rating + MatchParticipant row, same as the individual path ...
}

Why this is worth reading as one piece

  • The calculation class never touches a repository. It's the thing Unit Testing with Moq tests with plain [Theory] data instead of mocks — there's nothing to fake.
  • The cache doesn't know when it's stale on its own. RecordMatchAsync has to call InvalidateCache explicitly, for both the category and overall keys — exactly the "invalidate on write" rule from In-Memory Caching.
  • Individual and team matches share almost everything except how the actual/expected score is computed up front — a sign the per-player update loop was factored at the right boundary.