Getting the New Ratings

The leaderboard service keeps its own copy of the ratings in sorted sets. To keep the copy up to date, it needs to learn that a game ended, with:

  • the time control,
  • the two players,
  • and their new ratings.

The game service has all of this. When a game ends, it computes both new ratings and saves them in the player database. So the question is how the leaderboard service gets the new ratings from the game service. Let’s consider three designs.

The game service calls the leaderboard service

When a game ends, the game service could send a request to the leaderboard service with the new ratings.

Then the game service waits whenever the leaderboard service is slow. And when the leaderboard service is down, the call fails. The game service either drops the update, and the leaderboard is wrong, or it keeps retrying. Either way, a problem in the leaderboard service becomes a problem in the game service. That goes against the availability requirement.

The leaderboard service polls the player database

The leaderboard service could find the new ratings in the player database by itself. The reached_at column changes every time a rating changes. So once a second, on every shard, the leaderboard service could ask for the rows of ratings whose reached_at is later than the last time it asked. To make that fast, every shard needs an index on reached_at.

This has two costs. The polling adds load to the database the game service writes to. And the leaderboard service depends on the layout of a table it does not own, including an index that only it needs. If the game service’s team changes the ratings table, the leaderboard service can break. The two services would be tightly coupled through the table.

A message through a broker

The game service could send a message to a broker when a game ends, and the leaderboard service consumes it.

The game service sends the message and does not wait for the leaderboard service. If the leaderboard service is slow or down, the messages wait in the broker until it catches up. So the leaderboard service never holds up a game. This is asynchronous processing, and it meets the availability requirement.

I choose the message.

A queue or a topic

The game service can put the message on a queue for the leaderboard, or it can publish the message to a topic. The choice depends on whether any other service cares that a game ended. In the design so far, only the leaderboard service does. So a queue for the leaderboard is enough.

If another service later wants to act on a finished game, a topic is the better choice. For example, an analytics service might count the games played each day. With a topic, it subscribes with a queue of its own, and the game service does not change. With a queue, the game service would have to send a second message to the new service’s queue.

For now, I use a queue.

graph TD
    C[Clients: website, apps] --> G[Gateway]
    G --> PS[Player service]
    G --> LS[Leaderboard service]
    G -.->|WebSocket| GS[Game service]
    PS --> PDB[(Player database: sharded by player)]
    GS --> PDB
    GS -->|game ended| BR[Broker]
    BR -->|leaderboard queue| LS
    LS --> RS[(Leaderboard database: Redis sorted sets)]

The message

When a game ends, the game service saves the new ratings and then puts this message on the leaderboard queue:

{
  "gameId": 88123401,
  "timeControl": "blitz",
  "white": { "playerId": 5512, "username": "knightowl", "rating": 2148 },
  "black": { "playerId": 90311, "username": "pawnstorm", "rating": 2132 },
  "endedAt": "2026-11-03T15:31:20Z"
}

Both players were rated 2140 before this game. White won, so white went up 8 points and black went down 8.

The leaderboard service sets each player’s score in the blitz sorted set.

The message also carries both usernames, because a page of the leaderboard shows them.