Skip to content

Tutorials - Leaderboards

By Gramps


This tutorial will cover setting up leaderboards for your games.

Relevant GodotSteam classes and functions

Note

You may want to double-check our Initialization tutorial to set up initialization and callbacks functionality if you haven't done so already.

Set Up

Variables

First we will set up some basic variables that this tutorial uses:

var current_handle: int = 0
var leaderboard_handles: Dictionary[StringName, int] = {
    "leaderboard1_api_name": 0,
    "leaderboard2_api_name": 0,
    "leaderboard3_api_name": 0,
    }

The current_handle refers to the leaderboard handle we are actively using to pull entries or attach UGC to. This will be one of the integers in our leaderboard_handles dictionary. This variable is optional as GodotSteam actually stores the current leaderboard handle internally and can be called with Steam.leaderboard_handle.

Speaking of, the leaderboard_handles dictionary is simply just the leaderboard's Steamworks API name, set in the Steamworks partner site, and its current handle.

Signals

Now let's get our signals set up:

func _ready()-> void:
    Steam.connect("avatar_loaded", self, "_on_avatar_loaded")
    Steam.connect("leaderboard_find_result", self, "_on_leaderboard_find_result")
    Steam.connect("leaderboard_score_uploaded", self, "_on_leaderboard_score_uploaded")
    Steam.connect("leaderboard_scores_downloaded", self, "_on_leaderboard_scores_downloaded")
func _ready()-> void:
    Steam.avatar_loaded.connect(_on_avatar_loaded)
    Steam.leaderboard_find_result.connect(_on_leaderboard_find_result)
    Steam.leaderboard_score_uploaded.connect(_on_leaderboard_score_uploaded)
    Steam.leaderboard_scores_downloaded.connect(_on_leaderboard_scores_downloaded)

If you decide you want to attach UGC to your leaderboards, you will need a few more signals. Attaching UGC can happen one of two ways: using Steam Cloud features with the Remote Storage class or creating items with the UGC class. We will get into more details in the Uploading UGC To Leaderboard section.

Getting Handles

Unless you are creating leaderboards on the fly, we will just use findLeaderboard() which you will probably want to trigger once your leaderboard scene loads in:

Steam.findLeaderboard( achievement_handles.keys()[0] )

This will grab the first leaderboard API name in our dictionary and look for the handle.

Notes

When using findOrCreateLeader(), as opposed to findLeaderboard(), make sure not to set the sort method or display types as none. Avoid these enums: LEADERBOARD_SORT_METHOD_NONE and LEADERBOARD_DISPLAY_TYPE_NONE.

Once Steam finds your leaderboard it will pass back the handle to the leaderboard_find_result callback. The _on_leaderboard_find_result() function that it is connected to it should look something like this:

func _on_leaderboard_find_result(new_handle: int, was_found: int) -> void:
    if was_found != 1:
        print("Leaderboard handle cound not be found: %s" % was_found)
        return

    current_handle = new_handle

    var api_name: String = Steam.getLeaderboardName(new_handle)
    leaderboard_handles[api_name] = current_handle

    print("Leaderboard %s handle found: %s" % [api_name, current_handle])

The call to getLeaderboardName() will give us back the API name we just called (or should) and lets us update the leaderboard handle correctly. Big thanks to TriMay for the bit about getting the leaderboard name in the callback so we can assign the handle to the right leaderboard in our dictionary.

Once you have this handle you can use all the additional functions.

Getting Leaderboards In A Loop

Some users have wanted to pull all leaderboard handles from the get-go. While it is possible, it definitely feels like it wasn't intended to work that way. However, these bits of code should help grab all the leaderboard handles.

func get_handles_in_loop() -> void:
    for this_leaderboard in leaderboard_handles.keys():
        Steam.findLeaderboard(this_leaderboard)
        await Steam.leaderboard_find_result

This should loop through our dictionary, request each handle, and wait for Steam retrieve it then assign it using our callback function previously.

Originally, I had a timer for the await line await get_tree().create_timer(0.5).timeout which also worked pretty good. I did find that if you set the timeout to 0.25, it was too fast and some of the leaderboards would not be found. 0.5 was a sweet-spot that felt instant and reliably worked during testing.

Uploading Scores

Before we can download scores, we need to upload them. The function itself is pretty simple:

Steam.uploadLeaderboardScore( score, keep_best, details_array, leaderboard_handle )

Obviously, score is the new score we want to set. Depending on what you choose for keep_best this may not work if you pass a lower score than the previously set one.

keep_score sets whether or not to keep the higher / better score between the existing one and the one you are trying to update this leaderboard entry with.

details_array are details about the score but are completely optional. If you don't add any, just pass a blank array; if you do add some, these must be integers in a PackedInt32Array (or PoolIntArray in Godot 3.x versions). They essentially can be anything and there are some additional notes and wizardry in the Passing Extra Details section below. Even Valve says in their docs:

Details are optional game-defined information which outlines how the user got that score. For example if it's a racing style time based leaderboard you could store the timestamps when the player hits each checkpoint. If you have collectibles along the way you could use bit fields as booleans to store the items the player picked up in the playthrough.

Lastly, leaderboard_handles is the leaderboard handle we are updating. You do not have to pass the handle if you want to use the internally-stored one.

Notes

According to Valve's docs: "uploading scores to Steam is rate limited to 10 uploads per 10 minutes and you may only have one outstanding call to this function at a time."

Passing Extra Details

If you want to pass extra details, here are some neat hints from sepTN:

You can add small data as detail, for example embedding the character name (as a string) into that leaderboard entry. If you try to put detail that has a bigger size than possible, it will simply ignore it. To retrieve it, you need to process it again because Steam will spit out arrays (PackedInt32Array).

Here is some code that was shared:

# Godot 2 and 3 have no equivalent for to_int32_array I am aware of. Any corrections welcome!

Steam.uploadLeaderboardScore(score, keep_best, var2bytes(details), handle)
Steam.uploadLeaderboardScore(score, keep_best, var_to_bytes(details).to_int32_array(), handle)

When you download these scores and need to get our score details back to something readable, make sure you reverse this with bytes_to_var() or bytes2var() on Godot 4 and Godot 3 respectively.

If you want the score to be something like milliseconds, as one community member did, you will want to multiply that value by 1000 and submit it as an int.

Upload Callback

Once you pass a score to Steam, you should receive a callback from leaderboard_score_uploaded. This will trigger our _on_leaderboard_score_uploaded() function:

func _on_leaderboard_score_uploaded(success: int, this_handle: int, this_score: Dictionary) -> void:
    if success == 0:
        print("Failed to upload score to leaderboard %s" % this_handle)
        return

    print("Successfully uploaded score to leaderboard %s" % this_handle)

For the most part you are just looking for a success of 1 to tell that it worked. However, you may with to use the additional variables passed back by the signal for logic in your game. They are contained in the dictionary called this_score which contains these keys:

  • score: the new score as it stands
  • score_changed: if the score was changed (0 if false, 1 if true)
  • new_rank: the new global rank of this player
  • prev_rank: the previous rank of this player

Downloading Scores

Setting Max Details Or Not

Before we pull any leaderboard entries, you may want to set the maximum amount of details each entries contains in that details_array we saw earlier. If you do not save any details with the scores you can safely ignore this though.

By default, this is set to the maximum of 256; in GodotSteam versions 4.6 or older this will actually be set to 0. The value is stored internally by GodotSteam and can be accessed by Steam.leaderboard_details_max.

You can set this value to the number of details you expect to get back from this leaderboard but as long as the leaderboard_details_max is set to at least the highest number of details you should be fine. You can use set_leaderboard_details_max() to change this.

Getting Scores

In most cases you'll want to use downloadLeaderboardEntries() useless you just want to grab leaderboard entries for a specific group of players; in that case, you can use downloadLeaderboardEntriesForUsers() and pass an array of users' Steam IDs to it. Both will respond with the same callback:

Steam.downloadLeaderboardEntries( start_index, end_index, data_request_type, leaderboard_handle )

start_entry is the index to start at, relative to the value we set for data_request_type.

end_entry is the index to end at, relative to the value we set for data_request_type.

data_request_type is what kind of leaderboards you want to get, you can read more details about it in the SDK documentation. For a quick overview:

Leaderboard Data Request Enums Values Descriptions
LEADERBOARD_DATA_REQUEST_GLOBAL 0 Used for a sequential range by leaderboard rank.
LEADERBOARD_DATA_REQUEST_GLOBAL_AROUND_USER 1 Used to get entries relative to the user's entry. You may want to use negatives for the start to get entries before the user's.
LEADERBOARD_DATA_REQUEST_FRIENDS 2 Used to get all entries for friends for the current user. Start and end arguments are ignored.
LEADERBOARD_DATA_REQUEST_USERS 3 Used internally by Steam, do not use this.

Finally, leaderboard_handle is the leaderboard we are getting entries for. Just like uploading, downloading scores does not require a leaderboard handle to be included if you are using the internally-stored one.

If you want to just grab the leaderboard entries for a specific group of players, you can use downloadLeaderboardEntriesForUsers() instead:

var user_array: Array[int] = [
    steam_id1,
    steam_id2,
    steam_id3,
    steam_id4
    ]

Steam.downloadLeaderboardEntriesForUsers( user_array, leaderboard_handle )

We just need an array of Steam IDs, shown above as user_array. Then we just (optionally) pass the leaderboard_handle for the leaderboard we are getting entries for.

Download Callback

In either case, after you request leaderboard entries, you should receive a leaderboard_scores_downloaded callback which will trigger our _on_leaderboard_scores_downloaded() function. That function should look similar to this:

func _on_leaderboard_scores_downloaded(this_handle: int, these_results: Array) -> void:
    if these_results.size() == 0:
        print("No results found.")
        return

    if current_handle != this_handle:
        print("Oops, we got entries for a different handle: %s" % this_handle)
        return

    # Add logic to display results
    for this_result in result:
        # Use each entry that is returned

this_handle is the handle the accompanying entries are for. This should match the handle we called with.

these_results are all the leaderboard entries. Each entry in the array is actually a dictionary containing the following keys:

  • score: this user's score
  • steam_id: this user's Steam ID; you can use this to get their avatar, name, etc.
  • global_rank: obviously their global ranking for this leaderboard
  • ugc_handle: handle for any UGC that is attached to this entry
  • details: any details you stored with this entry for later use

Leaderboard Entry

For Skillet, we use a separate scene which gets instanced for each leaderboard entry in the for loop above. First we will want to amend our main leaderboard script with the following:

@onready var leaderboard_entry: Object = preload("res://leaderboard_entry.tscn")


func _ready()-> void:
    Steam.avatar_loaded.connect(_on_avatar_loaded)


func _on_avatar_loaded(avatar_id: int, avatar_size: int, avatar_data: Array) -> void:
    var avatar_image: Image = Image.create_from_data(avatar_size, avatar_size, false, Image.FORMAT_RGBA8, avatar_data)

    if avatar_size > 128:
        avatar_image.resize(128, 128, Image.INTERPOLATE_LANCZOS)
    var avatar_texture: ImageTexture = ImageTexture.create_from_image(avatar_image)

    get_node("List/Leader%s" % avatar_id).setup_texture(avatar_texture)

Next we will want to amend our _on_leaderboard_scores_downloaded() function's result for loop with the following:

for this_result in these_results:
    var this_leader: Object = leaderboard_entry.instantiate()
    this_leader.setup_entry(this_result)
    this_leader.name = "Leader%s" % this_result['steam_id']
    $List.add_child(this_leader)

Our leaderboard entry object is just a base HBoxContainer with some Labels, a TextureRect, and Button:

Leaderboard Entry

It has a script which we pass the these_results dictionary to setup_entry():

func setup_entry(these_details: Dictionary) -> void:
    Steam.getPlayerAvatar(these_details['steam_id'])
    %Avatar.texture = null
    %Name.text = Steam.getFriendPersonaName(these_details['steam_id'])
    %Rank.text = str(these_details['global_rank'])
    %Score.text = str(these_details['score'])
    %Details.text = str(these_details['details'])


func setup_texture(avatar_texture: ImageTexture) -> void:
    %Avatar.texture = avatar_texture

When the entry is added and updated with details, it will call getPlayerAvatar() to pull the user's avatar. As you may have noticed in our first amendment, this just calls the setup_texture() function in the appropriate leaderboard entry.

Uploading UGC to Leaderboard

There are two methods for adding UGC to your leaderboard entries: through Remote Storage's fileWriteAsync() and fileShare() methods or UGC's item creation methods.

For this tutorial, we will be using the Remote Storage method; the UGC method will be covered in the Workshop tutorial. First you will need to setup signals:

func _ready() -> void:
    Steam.connect("file_share_result", self, "_on_file_share_result")
    Steam.connect("file_write_async_complete", self, "on_file_write_async_complete")
    Steam.connect("leaderboard_ugc_set", self, "on_leaderboard_ugc_set")
func _ready() -> void:
    Steam.file_share_result.connect(_on_file_share_result)
    Steam.file_write_async_complete.connect(_on_file_write_async_complete)
    Steam.leaderboard_ugc_set.connect(_on_leaderboard_ugc_set)

Next we need to convert our file to a PackedByteArray and pass it to fileWriteAsync():

var ugc_file_name: String = "this_ugc_file.png"
var ugc_path: String = "/path/to/file/%s" % ugc_file_name

var data: PackedByteArray = FileAccess.get_file_as_bytes( ugc_path )
Steam.fileWriteAsync(ugc_file_name, data, data.size())

File will be created in location ...\Steam\userdata\<steam 32-bit id>\<appid>\remote and should be pushed to Steam Cloud. Obviously it doesn't have to be a .png, it can be any kind of file you want to share for this. We will check this worked and then share it in the fileWriteAsync() callback function:

func on_file_write_async_complete(write_result: int) -> void:
    if write_result != Steam.RESULT_OK:
        print("Failed to write UGC file: %s" % write_result)
        return
    print("Sharing UGC file")

    if not Steam.fileExists(ugc_file_name):
        print("Failed does not exist in Steam Cloud")
        return

    Steam.fileShare(ugc_file_name)

We check if it even exists in Steam Cloud first, with fileExists(). If so, then fileShare() is used to mark existing files to be shared that are present in remote local directory / Steam Cloud.

In our callback function from fileShare() we will finally attach our UGC to the leaderboard:

func on_file_share_result(share_result: int, this_handle, this_name: String ) -> void:
    if this_result != Steam.RESULT_OK:
        print("Failed to share UGC file %s: %s" % [this_name, share_result])
        return

    print("Successfully shared UGC file %s" % this_name)
    Steam.attachLeaderboardUGC(this_handle, current_handle)

Be careful here with all the handle names, the first one for attachLeaderboard() is the UGC handle we are sharing and the second is the leaderboard handle we are attaching this UGC to. Lastly we will check the callback for attachLeaderboard():

func on_leaderboard_ugc_set(this_handle: int, result: Steam.Result) -> void:
    if this_result == Steam.RESULT_TIMEOUT:
        print("UGC took too long ot upload; try again.")
        return
    if this_result == Steam.RESULT_INVALID_PARAM:
        print("Leaderboard handle was invalid: %s" % this_handle)
        return
    if this_result != Steam.RESULT_OK:
        print("Random error: %s" % this_result)
        return

    print("Leaderboard UGC was set: %s" % this_handle)

This UGC handle that gets returned should match what you get when you download entries. This can be used with a variety of methods to retrieve the UGC which we will talk about in the Retrieveing UGC section.

Mangled Handles

At the time of writing, I didn't find a way to correct represent the UGC handle so it shows up as a negative integer in Godot. This handle still works perfectly fine.

Callback Changes

In GodotSteam 4.15 / 3.29 or earlier, the leaderboard_ugc_set callback foolishly sends the result back as a String instead of a Steam result enum value. This is fixed in GodotSteam 4.16 / 3.30 and later.

Retrieving UGC

For our example, we will add a button to every leaderboard entry that has UGC attached. We will modify our previous setup_entry() function in our leaderboard_entry.gd:

    func setup_entry(these_details: Dictionary) -> void:
        Steam.getPlayerAvatar(these_details['steam_id'])
        %Avatar.texture = null
        %Name.text = Steam.getFriendPersonaName(these_details['steam_id'])
        %Rank.text = str(these_details['global_rank'])
        %Score.text = str(these_details['score'])
        %Details.text = str(these_details['details'])
        # Used for attached UGC
        %UGC.visible = true if these_details['ugc_handle'] != 0 else false
        %UGC.pressed.connect(_on_ugc_pressed.bind(these_details['ugc_hanadle']))

We will then create the related function _on_ugc_pressed() which takes our handle and grabs the UGC:

func _on_ugc_pressed(this_ugc_handle: int) -> void:
    print("UGC Details: %s" % Steam.getUGCDetails(this_ugc_handle))

    var ugc_absolute_path: String = ProjectSettings.globalize_path("user://leaderboards/ugc/%s.png" % this_ugc_handle)

    Steam.ugcDownloadToLocation(this_ugc_handle, ugc_absolute_path, 0)

The call to ugcDownload() works and responds with a successful callback; however, I cannot seem to find where the file downloads. So we will be using ugcDownloadToLocation() to grab and place the UGC content where ever you want. We will be putting them in the user:// folder.

Our ugcDownloadToLocation() will trigger a download_ugc_result callback and our _on_download_ugc_result() function:

func _on_download_ugc_result(result: Steam.Result, download_data: Dictionary) -> void:
    if result != Steam.RESULT_OK:
        print("Failed to download: %s" % result)
        return

    var new_ugc_image: Image = Image.new()
    new_ugc_image.load(ProjectSettings.globalize_path("user://leaderboards/ugc/%s.png" % download_data['handle']))
    var new_ugc_texture: ImageTexture = ImageTexture.create_from_image(new_ugc_image)
    %UGCImage.texture = new_ugc_texture
    %UGC.visible = true

This will load in our image and set it as the texture for our UGC modal that displays.

Leaderboard UGC Modal

And that is our basic setup for leaderboards!

Possible Oddities

A user in our Discord noted that sometimes downloadLeaderboardEntriesForUsers() would trigger a callback but have zero entries. Oddly, they reported that creating a second leaderboard then deleting the first one would fix this. While I don't understand why this would be the case, in the event you come across this, perhaps try this solution!

Additional Resources

Video Tutorials

Prefer video tutorials? Feast your eyes and ears!

'How To Build Leaderboards Out' by FinePointCGI

'Godot 4 Steam Leaderboards' by Gwizz

Example Project

Later this year you can see this tutorial in action with more in-depth information by checking out our upcoming free-to-play game Skillet on GitHub. There you will be able to view of the code used which can serve as a starting point for you to branch out from.