Tournaments with Fixed-Capacity Leaderboards

I am looking to implement a tournament system in Nakama where users that join the tournament get assigned to a fixed-capacity leaderboard. When the leaderboard reaches its capacity, the next person that joins the tournament gets assigned to a new leaderboard (this new leaderboard should be created and owned by the server, not the player). Players are assigned to each leaderboard in sequence; no matchmaking is necessary to assign players of similar skill to a leaderboard, and there should be no limit to the number of leaderboards that can be created this way. When the tournament comes to an end, the top players on each leaderboard are awarded a prize, and the leaderboards are deleted a few days later so that each player’s ranking can be viewed for the few days following the tournament.

For example, players get notified that a tournament has started, either through a notification or by logging into the game and seeing a user interface element showing them that a tournament has begun. The player optionally chooses to join the tournament by clicking the user interface element, and is then immediately assigned to a leaderboard with up to 49 other players. When the tournament ends, the player enters the game and can view the leaderboard, along with their final ranking, and claim any prizes they may have been awarded as a result of their final ranking in the tournament.

What would be a good way to approach creating such a system while ensuring the system remains scalable in Nakama?

1 Like

@jamesbaud For this use case I wouldn’t use the Tournament API in Nakama but just use a collection of leaderboards. You can control all the logic you’ve described around them with a few RPC functions written for the server runtime. There’s a callback that you can register for at server startup that will be called when the leaderboard expires.

The only part that’s unclear is the approach you want to take to fill each leaderboard before the next one. It might be better to predefine a number of leaderboards (which you could adjust as the player base grows) and hash their user ID to place the player into one of the leaderboard “shards”. This way while leaderboards may not get full you can more evenly spread the number of players per leaderboard. What do you think?

I would much rather prefer to fill an empty leaderboard before creating a new one. This will make rewards predictable for players that join the tournament (e.g., the top several players in each leaderboard receive an award, rather than adjusting rewards based on the number of players in each leaderboard at the end of the tournament). And filling the incomplete leaderboard first will also populate it more quickly. This is important to give players that join the tournament within the same window of time the most fair chance of receiving an award when the tournament ends (because the tournament is a fixed duration and allows players to earn additional points by investing time, players who join the same leaderboard have a more equal chance at winning a reward).

Many games, both desktop and mobile, implement leaderboards in a similar way (e.g., StarCraft II). They restrict the number of players in each leaderboard so that players have a more meaningful path of progress. Many mobile games do something even more similar to what I described above. A leaderboard/tournament solution where players can invest additional time to earn more points on the leaderboard is much better at driving player engagement and improving retention than having one giant leaderboard for all players.

What I’m particularly interested in knowing is where and how to store the ID of the incomplete leaderboard (or create a new one if the previous leaderboard is full, or if it is the first leaderboard of the tournament). I want to ensure that the operation has some sort of mutex that prevents other players from joining an already-full leaderboard until the previous request has been fully processed. I would like to know where in Nakama to store this global data (i.e., the incomplete leaderboard ID) so that if the system were to scale to multiple instances, this information is shared and accessible to each instance. I assume I will need to perform some sort of custom SQL query. But if multiple clients make an RPC call to the server, are requests serviced in multiple threads, or are they processed in sequence? If they are processed simultaneously, I want to be careful so that only one leaderboard gets created, and all simultaneous requests get added to that new leaderboard. Each simultaneous request should not create its own leaderboard.

For this use case I wouldn’t use the Tournament API in Nakama but just use a collection of leaderboards

I was thinking the same here. The only part of the Tournament API that would be useful is the hook that gets called when the tournament ends, but that can be re-implemented in a custom RPC function if necessary.

@novabyte Can you provide some answers to the following few questions:

What I’m particularly interested in knowing is where and how to store the ID of the incomplete leaderboard (or create a new one if the previous leaderboard is full, or if it is the first leaderboard of the tournament). I want to ensure that the operation has some sort of mutex that prevents other players from joining an already-full leaderboard until the previous request has been fully processed. I would like to know where in Nakama to store this global data (i.e., the incomplete leaderboard ID) so that if the system were to scale to multiple instances, this information is shared and accessible to each instance. I assume I will need to perform some sort of custom SQL query. But if multiple clients make an RPC call to the server, are requests serviced in multiple threads, or are they processed in sequence? If they are processed simultaneously, I want to be careful so that only one leaderboard gets created, and all simultaneous requests get added to that new leaderboard. Each simultaneous request should not create its own leaderboard.

Does anyone have any insight or suggestions here?

@jamesbaud Sorry for the slow follow up on this post. To summarize your use case its as follows:

  • Tournaments run as a one-off (no fixed time reset schedule). They’re manually scheduled by you and your team.
  • A player can receive a push notification or in-app notification the next time they open the game to indicate a new tournament is active. They can choose to join the tournament.
  • When a player joins they will compete with some set of other players who’ve also joined the tournament. There’s no skill measure to segment players against other players. The opponents players compete against is entirely driven by the time that the surrounding players joined the tournament.
  • The players who join the tournament fill up slots until a max number of players and then the next players in turn join the next tournament segment to play against their own group of opponents.
  • At the end of the tournament players get distributed rewards based on their position within the small group of opponents they compete against.

Let me know if this summary is accurate.

To answer some of your questions:

The only part of the Tournament API that would be useful is the hook that gets called when the tournament ends

There’s a hook for leaderboards which works the same way as the hook for tournaments so you’ve no need to implement a different approach.

The best way I can think to achieve this kind of leaderboard approach is with these steps. It’s a bit complicated because its an unusual approach to take with a leaderboard system but hope it helps.

  1. The game client uses a storage object to determine whether a new tournament has started which can be joined from the UI. This is a simple remote configuration approach and can be made more complex based on what criteria you want to have to determine when a tournament runs.
  2. When the client sees that it can join a tournament it executes an RPC function on the server.
  3. The RPC function works as follows:
    1. It fetches the player’s metadata to look for a key to indicate whether a tournament is already active for the player.
    2. If there’s no active tournament or the current one has expired (we’ll use the tournament key name to indicate this value) then the code fetches a storage object that contains a “pointer” to the current tournament name to try and join.
    3. If there’s no value in that pointer because no tournament has been created for the current run then a tournament with the name of the pattern “week37_1” is created. The specific composition of the tournament key name should be adjusted to fit the run period of the tournament.
    4. If the tournament already exists with the key name then in the pointer then attempt to join it.
    5. If the tournament create from step (3) fails then it means the tournament already exists and proceed to step (4).
    6. If the join fails for the tournament because its full then update the “pointer” storage object to contain “week37_2” and create that tournament. If that create fails then proceed to step (4).
    7. Finally update the player’s metadata to contain the name of the tournament they are active in.

The above pattern uses the design of the tournament system in Nakama to prevent too many players from joining a tournament (because it can have a max_size) and the tournament create error indicates when the player should shift to a join attempt to prevent them from landing in a tournament that is by themselves. The key name used is a composite structure so the clients can know that whichever one calls the RPC function should know how to name the next tournament in the sequence.

@novabyte Overall, your summary is pretty accurate. For my specific case, however, tournaments DO have a fixed time reset schedule (i.e., new tournaments are created weekly and terminate after a fixed amount of time), and while players compete for the highest score within their assigned leaderboard, there is no direct interaction between players; the only “interactive” piece is the rewards that are awarded to the players at the top of the leaderboard when the tournament ends.

I will try to implement your suggestion, which appears to answer one of my big questions–when the system scales across multiple instances, only one RPC function is guaranteed to run at a time to avoid the potential race condition where two players join a tournament with only one open slot, at the same time. Otherwise, what would prevent two separate instances from retrieving the “pointer” to the current tournament at the same time before either has a chance to set the value back to the storage object? More specifically, what synchronizes the two separate system instances so that all RPC functions are guaranteed to run without stepping on each other?

only one RPC function is guaranteed to run at a time to avoid the potential race condition where two players join a tournament with only one open slot

This what the optimistic concurrency control is used for in the storage engine. It’s well suited to low contention scenarios like this where you want only one writer to succeed in the object update. You can then have the second writer re-read the value and write again.

Otherwise, what would prevent two separate instances from retrieving the “pointer” to the current tournament at the same time before either has a chance to set the value back to the storage object?

The writer which reached and committed to the storage engine would win. The second instance would receive an error and be required to retry. This involves passing the version field into the write operation after the storage object has been updated with its “pointer”:

https://heroiclabs.com/docs/storage-collections/#conditional-writes

More specifically, what synchronizes the two separate system instances so that all RPC functions are guaranteed to run without stepping on each other?

The approach taken by OCC avoids the need for slow serializable access to a contended resource. It works on the assumption that the resource who’s access needs to be “locked” is typically under low contention when concurrent writers do attempt to change it (less than 100s of writes per seconds). I believe this should be no problem for your use case.

Hope this helps.

Hi,

Looks like to avoid race condition we have to use storage engine, that’s what i have understood with last reply from novabyte.

But what if e.g we have a function in lua at server side module “join” and we want this function to be called synchronously so that we many players are hitting this function from client side and if limit is full only the last one can join and all other request go un-served with a potential message from server side that limit reached or etc something like that ?

May be it is language (lua) specific implementation ?

Thanks.