7

Introduction

I have a highscore table for my game which uses ranks. The scores table represents current highscores and player info and the recent table represents all recently posted scores by a user which may or may not have been a new top score.

The rank drop is calculated by calculating the player's current rank minus their rank they had at the time of reaching their latest top score.

The rank increase is calculated by calculating the player's rank they had at the time of reaching their latest top score minus the rank they had at the time of reaching their previous top score.

Finally, as written in code: $change = ($drop > 0 ? -$drop : $increase);


Question

I am using the following two queries combined with a bit of PHP code to calculate rank change. It works perfectly fine, but is sometimes a bit slow.

Would there be a way to optimize or combine the two queries + PHP code?

I created an SQL Fiddle of the first query: http://sqlfiddle.com/#!9/30848/1

The tables are filled with content already, so their structures should not be altered.

This is the current working code:

$q = "
            select
            (
            select
                coalesce(
                    (
                        select count(distinct b.username)
                        from recent b
                        where
                            b.istopscore = 1  AND
                            (
                                (
                                    b.score > a.score AND
                                    b.time <= a.time
                                ) OR
                                (
                                    b.score = a.score AND
                                    b.username != a.username AND
                                    b.time < a.time
                                )
                            )
                        ), 0) + 1 Rank
            from scores a
            where a.nickname = ?) as Rank,
            t.time,
            t.username,
            t.score
            from
            scores t
            WHERE t.nickname = ?
            ";

            $r_time = 0;

            if( $stmt = $mysqli->prepare( $q ) )
            {
                $stmt->bind_param( 'ss', $nick, $nick );
                $stmt->execute();
                $stmt->store_result();
                $stmt->bind_result( $r_rank, $r_time, $r_username, $r_score );

                $stmt->fetch();

                if( intval($r_rank) > 99999 )
                    $r_rank = 99999;

                $stmt->close();
            }

            // Previous Rank
            $r_prevrank = -1;

            if( $r_rank > -1 )
            {
                $q = "
                select
                    coalesce(
                        (
                            select count(distinct b.username)
                            from recent b
                            where
                                b.istopscore = 1  AND
                                (
                                    (
                                        b.score > a.score AND
                                        b.time <= a.time
                                    ) OR
                                    (
                                        b.score = a.score AND
                                        b.username != a.username AND
                                        b.time < a.time
                                    )
                                )
                            ), 0) + 1 Rank
                from recent a
                where a.username = ? and a.time < ? and a.score < ?
                order by score desc limit 1";

                if( $stmt = $mysqli->prepare( $q ) )
                {
                    $time_minus_one = ( $r_time - 1 );

                    $stmt->bind_param( 'sii', $r_username, $time_minus_one, $r_score );
                    $stmt->execute();
                    $stmt->store_result();
                    $stmt->bind_result( $r_prevrank );

                    $stmt->fetch();

                    if( intval($r_prevrank) > 99999 )
                        $r_prevrank = 99999;

                    $stmt->close();
                }
                $drop = ($current_rank - $r_rank);
                $drop = ($drop > 0 ? $drop : 0 );


                $increase = $r_prevrank - $r_rank;
                $increase = ($increase > 0 ? $increase : 0 );

                //$change = $increase - $drop;
                $change = ($drop > 0 ? -$drop : $increase);
            }

            return $change;
Z0q
  • 1,689
  • 3
  • 28
  • 57
  • Maybe switching to PDO makes it a bit faster, but it probably won't do anything. – Tom Feb 13 '16 at 14:54
  • Could you please explain: – gfunk Feb 13 '16 at 20:15
  • 1
    Could you please define 1. what the two tables represent & 2. what your algorithm/math is for computing rank? From what I've figured my rank is the number of other peoples' recent entries that scored higher *and* occurred earlier (in time) than my record in the "scores" table. plus ignoring !istopscoes – gfunk Feb 13 '16 at 20:26
  • @gfunk 1. The scores table represents current highscores and player info and the recent table represents all recently posted scores by a user which may or may not have been a new top score. 2. The `rank drop` is calculated by calculating the player's current rank minus their rank they had at the time of reaching their latest top score. -- The `rank increase` is calculated by calculating the player's rank they had at the time of reaching their latest top score minus the rank they had at the time of reaching their previous top score. Finally, as written in code, `$change = $increase - $drop;`. – Z0q Feb 15 '16 at 12:04
  • 2
    Including sample data along with expected outcome and the reasoning for that expected outcome (i.e., explaining your terms like "rank") would probably go a long way to helping others see what you're trying to do and to come up with an answer. http://stackoverflow.com/help/how-to-ask – Tom H Feb 15 '16 at 21:11
  • @TomH I updated my question – Z0q Feb 16 '16 at 13:34
  • But I still don't see any sample data and expected outcome. – Tom H Feb 16 '16 at 20:25
  • I see that in your fiddle, the *scores* table indicates that Beta has a highscore of 321, at time 300. But then in the *recent* table player Beta has a topscore of 500, at time 500. Why is that not a high score figuring in the table *score*? Second example: user Echo has in the *recent* table a topscore of 100 at time 90, but earlier, at time 60 he had already scored higher (200). I don't see the logic in these data. – trincot Feb 16 '16 at 21:42
  • @trincot I just filled the table with some random numbers. – Z0q Feb 17 '16 at 10:19

4 Answers4

3

If you are separating out the current top score into a new table, while all the raw data is available in the recent scores.. you have effectively produced a summary table.

Why not continue to summarize and summarize all the data you need?

It's then just a case of what do you know and when you can know it:

  • Current rank - Depends on other rows
  • Rank on new top score - Can be calculated as current rank and stored at time of insert/update
  • Previous rank on top score - Can be transferred from old 'rank on new top score' when a new top score is recorded.

I'd change your scores table to include two new columns:

  • scores - id, score, username, nickname, time, rank_on_update, old_rank_on_update

And adjust these columns as you update/insert each row. Looks like you already have queries that can be used to backfit this data for your first iteration.

Now your queries become a lot simpler

To get rank from score:

SELECT COUNT(*) + 1 rank
  FROM scores 
 WHERE score > :score

From username:

SELECT COUNT(*) + 1 rank
  FROM scores s1
  JOIN scores s2
    ON s2.score > s1.score
 WHERE s1.username = :username

And rank change becomes:

  $drop = max($current_rank - $rank_on_update, 0);
  $increase = max($old_rank_on_update - $rank_on_update, 0);
  $change = $drop ? -$drop : $increase;

UPDATE

  • Comment 1 + 3 - Oops, may have messed that up.. have changed above.
  • Comment 2 - Incorrect, if you keep the scores (all the latest high-scores) up to date on the fly (every time a new high-score is recorded) and assuming there is one row per user, at the time of calculation current rank should simply be a count of scores higher than the user's score (+1). Should hopefully be able to avoid that crazy query once the data is up to date!

If you insist on separating by time, this will work for a new row if you haven't updated the row yet:

SELECT COUNT(*) + 1 rank
  FROM scores 
 WHERE score >= :score

The other query would become:

SELECT COUNT(*) + 1 rank
  FROM scores s1
  JOIN scores s2
    ON s2.score > s1.score 
    OR (s2.score = s1.score AND s2.time < s1.time) 
 WHERE s1.username = :username

But I'd at least try union for performance:

SELECT SUM(count) + 1 rank
  FROM ( 
    SELECT COUNT(*) count
      FROM scores s1
      JOIN scores s2
        ON s2.score > s1.score
     WHERE s1.username = :username
     UNION ALL
    SELECT COUNT(*) count
      FROM scores s1
      JOIN scores s2
        ON s2.score = s1.score
       AND s2.time < s1.time
     WHERE s1.username = :username
       ) counts

An index on (score, time) would help here.

Personally I'd save yourself a headache and keep same scores at the same rank (pretty standard I believe).. If you want people to be able to claim first bragging rights just make sure you order by time ASC on any score charts and include the time in the display.

Arth
  • 12,789
  • 5
  • 37
  • 69
  • Sounds like a great answer. The first part completely makes sense to me, however, the second part under `Now your queries become a lot simpler`, I don't fully understand. Could you please elaborate on that? Why would you suggest to change my final rank change calculation from `$change = $increase - $drop;` to `$change = $old_rank_on_update - $rank`? And how does it work and make sense to players? – Z0q Feb 18 '16 at 10:52
  • To calculate the current rank of a player, I believe I need to use the current query I use, because it contains all the checks, including time, etc. Correct? – Z0q Feb 18 '16 at 10:58
  • Sorry, the final calculation is currently actually `$change = ($drop > 0 ? -$drop : $increase);`. – Z0q Feb 18 '16 at 11:12
  • Great, thanks! Now the reason I talked about comment 2, was because I use time to sort scores which are equal. So if a user has the same score, but posted earlier, the one who posted that score the earliest will have the highest rank. Do I explain it well? – Z0q Feb 18 '16 at 13:14
0

I spent a lot of time trying to figure out what the rank logic is and put in a comment about it. In the meantime, here is a join query that you can run on your data - I think your solution will something something to this effect:

SELECT s.username, count(*) rank
FROM scores s LEFT JOIN recent r ON s.username != r.username 
WHERE r.istopscore 
AND r.score >= s.score 
AND r.time <= s.time 
AND (r.score-s.score + s.time-r.time) 
GROUP BY s.username
ORDER BY rank ASC;

+----------+------+
| username | rank |
+----------+------+
| Beta     |    1 |
| Alpha    |    2 |
| Echo     |    3 |
+----------+------+

(note that last AND is just to ensure you don't account for r.score==s.score && r.time==s.time - which i guess would be a "tie" game?)

gfunk
  • 381
  • 1
  • 14
  • Thank you for taking the time. Is the purpose of this query to calculate the rank? It doesn't work. And also, I need to calculate the rank difference. – Z0q Feb 15 '16 at 12:15
0

I am not a MySQL guy, but I think that using self-join for ranking is a bad practice in any RDBMS. You should consider using of ranking functions. But there are no ranking functionality in MySQL. But there are workarounds.

Community
  • 1
  • 1
Dmitriy Dokshin
  • 710
  • 5
  • 25
0

There are some assumptions that have to be made here in order to move forward with this. I assume that the scores table has only one entry per 'username' which is somehow equivalent to a nickname.

Try this,

If I had a working db, this would be quick to figure out and test, but basically you are taking the 'sub query' you are running in the selected field and you are building a temp table with ALL of the records and filtering them out.

       select a.nickname
            , count(distinct b.username) as rank
            , t.time
            , t.username
            , t.score
        from
        (  
                select 
                    a.nickname
                    , b.username
                from (select * from scores where nickname=? ) a
                    left join (select * from recent where istopscore = 1) as b
                on (
                        b.score > a.score and b.time <= a.time -- include the b record if the b score is higher
                        or 
                        b.score = a.score and b.time < a.time and a.username != b.username -- include b if the score is the same,  b got the score before a got the score
               )
         ) tmp
         join  scores t  on (t.nickname = tmp.nickname)
         where t.nickname = ?

I did not attempt to address your later logic, you can use the same theory, but it is not worth trying unless you can confirm that this method returns the correct rows.

If you would like to get deeper, you should create some data sets and fully setup the SQL Fiddle.

Michael Blood
  • 1,257
  • 10
  • 11
  • Thank you. I will try this. The `username` is used for email addresses which identify users and the `nickname` is also unique, but can be changed. Yes, the scores table contains one entry per user. There is one thing I am concerned about: The `recent` table is huge (200K+ rows). I would prefer to check whether or not the usernames are identical instead of the nicknames (`t.nickname = a.nickname`). But that is okay. – Z0q Feb 16 '16 at 10:37
  • On the line before the last line, it stops: `#1054 - Unknown column 'a.nickname' in 'on clause' `. I don't understand how you are trying to combine and join these two queries, so I don't know how to fix it. It seems that MySQL doesn't allow the fields of the inner SELECT query to be used outside of it. – Z0q Feb 16 '16 at 10:51
  • On the last line, instead of a.nickname it should have been tmp.nickname I have updated the SQL. Good luck with it – Michael Blood Feb 17 '16 at 12:11
  • It's not working. When I enter my username, it returns: `rank: 0, time: NULL, username: NULL, top: NULL`. A rank can never be `0`. – Z0q Feb 18 '16 at 10:41
  • Sorry, I would need example data in order to pursue any further, perhaps you can look over the way that I have moved the logic from the SELECT to the FROM and you can find a way to make that work with the data schema you understand. In my experience, that has been a helped speed up slow queries. Good luck – Michael Blood Feb 19 '16 at 12:43