14

I have a Player class with a score attribute:

class Player(game_engine.Player):

    def __init__(self, id):
        super().__init__(id)
        self.score = 0

This score increases/decreases as the player succeeds/fails to do objectives. Now I need to tell the player his rank out of the total amount of players with something like

print('Your rank is {0} out of {1}')

First I thought of having a list of all the players, and whenever anything happens to a player:

  1. I check if his score increased or decreased
  2. find him in the list
  3. move him until his score is in the correct place

But this would be extremely slow. There can be hundreds of thousands of players, and a player can reset his own score to 0 which would mean that I'd have to move everyone after him in the stack. Even finding the player would be O(n).

What I'm looking for is a high performance solution. RAM usage isn't quite as important, although common sense should be used. How could I improve the system to be a lot faster?

Updated info: I'm storing a player's data into a MySQL database with SQLAlchemy everytime he leaves the gameserver, and I load it everytime he joins the server. These are handled through 'player_join' and 'player_leave' events:

@Event('player_join')
def load_player(id):
    """Load player into the global players dict."""
    session = Session()
    query = session.query(Player).filter_by(id=id)
    players[id] = query.one_or_none() or Player(id=id)

@Event('player_leave')
def save_player(id):
    """Save player into the database."""
    session = Session()
    session.add(players[id])
    session.commit()

Also, the player's score is updated upon 'player_kill' event:

@Event('player_kill')
def update_score(id, target_id):
    """Update players' scores upon a kill."""
    players[id].score += 2
    players[target_id].score -= 2
Markus Meskanen
  • 19,939
  • 18
  • 80
  • 119
  • which database you use? – r-m-n Aug 16 '16 at 06:49
  • @r-m-n I'm using MySQL – Markus Meskanen Aug 16 '16 at 09:24
  • in some databases it could be done with DENSE_RANK window function, but MySQL don't support this function. You could try something like this http://dukesoftware00.blogspot.ru/2012/11/calculate-denserank-for-mysql.html – r-m-n Aug 16 '16 at 09:58
  • 1
    I have handled this using a redis sorted set- there are simple commands to get the rank of specific keys for example, and redis is going to be much faster than anything you try to build yourself without a vast investment. – Paul Becotte Aug 17 '16 at 14:16
  • You could have an hourly task which makes a sorted list of scores, which would allow you to quickly determine the current rank with something like bisect.bisect_left on that cached list. The list of scores does not need to be complete -- maybe keep every 1000th score. You can go to db to get exact rankings for the actual top players. – Kenny Ostrom Aug 17 '16 at 14:29
  • @KennyOstrom I'd prefer it to update in pretty much realtime, so players can at all times see their rank and see in real-time how they're progressing on the lists. – Markus Meskanen Aug 17 '16 at 14:38
  • They can see their real-time progress as they progress through the list, as long as that list is a reasonable cache. – Kenny Ostrom Aug 17 '16 at 14:58
  • @KennyOstrom I'm not sure what you mean, doesn't this just come back to the original issue I represented in my main post? – Markus Meskanen Aug 17 '16 at 15:12
  • No, it is trivially fast (lg n in memory) to get the realtime rank of the player compared to a cached list of scores, and you can manage the hidden costs by updating the score cache only as often as you feel necessary, rather than trying to fully requery 100k db records every time someone glances at their rank. – Kenny Ostrom Aug 17 '16 at 15:22
  • @KennyOstrom Wanna add it as an answer? :P – Markus Meskanen Aug 17 '16 at 15:24
  • Incidentally, if you need the absolute latest info from the db every time, then just add the proper index to the table, and the db should be able to give you that info as fast as is possible with a query. See http://stackoverflow.com/questions/5436263/ranking-with-millions-of-entries – Kenny Ostrom Aug 17 '16 at 16:18

3 Answers3

6

Redis sorted sets help with this exact situation (the documentation uses leader boards as the example usage) http://redis.io/topics/data-types-intro#redis-sorted-sets

  • The key commands you care about are ZADD (update player rank) and ZRANK (get rank for specific player). Both operations are O(log(N)) complexity.

Redis can be used as a cache of player ranking. When your application starts, populate redis from the SQL data. When updating player scores in mysql also update redis.

If you have multiple server processes/threads and they could trigger player score updates concurrently then you should also account for the mysql/redis update race condition, eg:

  • only update redis from a DB trigger; or
  • serialise player score updates; or
  • let data get temporarily out of sync and do another cache update after a delay; or
  • let data get temporarily out of sync and do a full cache rebuild at fixed intervals
Levi
  • 760
  • 4
  • 8
4

The problem you have is that you want real-time updates against a database, which requires a db query each time. If you instead maintain a list of scores in memory, and update it at a more reasonable frequency (say once an hour, or even once a minute, if your players are really concerned with their rank), then the players will still experience real-time progress vs a score rank, and they can't really tell if there is a short lag in the updates.

With a sorted list of scores in memory, you can instantly get the player's rank (where by instantly, I mean O(lg n) lookup in memory) at the cost of the memory to cache, and of course the time to update the cache when you want to. Compared to a db query of 100k records every time someone wants to glance at their rank, this is a much better option.

Elaborating on the sorted list, you must query the db to get it, but you can keep using it for a while. Maybe you store the last_update, and re-query the db only if this list is "too old". So you update quickly by not trying to update all the time, but rather just enough to feel like real-time.

In order to find someone's rank nearly instantaneously, you use the bisect module, which supports binary search in a sorted list. The scores are sorted when you get them.

from bisect import bisect_left

# suppose scores are 1 through 10
scores = range(1, 11)

# get the insertion index for score 7
# subtract it from len(scores) because bisect expects ascending sort
# but you want a descending rank
print len(scores) - bisect_left(scores, 7)

This says that a 7 score is rank 4, which is correct.

Kenny Ostrom
  • 5,639
  • 2
  • 21
  • 30
  • `"With a sorted list of scores in memory, you can instantly get the player's rank (where by instantly, I mean O(lg n) lookup in memory) at the cost of the memory to cache"` this is pretty much the issue in my main post (not the database issue, I barely mention database in the post). How can you update and lookup so quickly through a list, while keeping it sorted? – Markus Meskanen Aug 17 '16 at 15:42
  • I updated with sample code for that part. If caching info that's not 100% up to date with the db is a dealbreaker, let me know, so I can delete this. I wasn't sure about that, which is why I just posted it as a comment, earlier. – Kenny Ostrom Aug 17 '16 at 16:14
0

That kind of information can be pulled using SQLAlchemy's sort_by function. If you perform a Query like:

leaderboard = session.query(Player).order_by(Player.score).all()

You will have the list of Players sorted by their score. Keep in mind that every time you do this you do an I/O with the database which can be rather slow instead of saving the data python variables.

Kostas Pelelis
  • 1,322
  • 9
  • 11