GodotSteam: how to upload and download user-generated content (UGC) (repost)

This is a re-post from my old blog on another platform. 

If you want your players to be able to upload and download any content to and from Steam Workshop with GodotSteam, here is how I do it. I hope this example helps other developers who might be confused by the Steamworks documentation as I was. Below is the code that I have in Ailin: Traps and Treasures (ATnT) with clutter and some game-specific fragments removed and some comments added.In my example, the terms level and UGC item are basically interchangeable.I use pre-compiled GodotSteam 3.17 with SteamSDK 1.55.

Let's start with initialization

First, a good idea might be to create a dedicated script file which would handle all Steam-related interactions and would be a singleton. I called this file SteamIntegration.gd in ATnT. In the ready function, I connect a number of signals (see below) and initialize Steam following this tutorial.

func _ready():

    Steam.connect("steamworks_error", self, "_log_error", [], CONNECT_PERSIST)

    # init steam

    Steam.connect("current_stats_received", self, "_steam_Stats_Ready", [], CONNECT_PERSIST)

    # callbacks to connect

    Steam.connect("item_created", self, "_on_item_created", [], CONNECT_PERSIST)

    Steam.connect("item_updated", self, "_on_item_updated", [], CONNECT_PERSIST)

    Steam.connect("ugc_query_completed", self, "_on_ugc_query_completed", [], CONNECT_PERSIST)

    Steam.connect("item_downloaded", self, "_on_item_downloaded", [], CONNECT_PERSIST)

    Steam.connect("overlay_toggled", self, "_on_overlay_toggle", [], CONNECT_PERSIST)

    Steam.steamInit()

    Steam.requestCurrentStats()

The global variable current_item_id is a workaround, which I use to upload a new UGC item.

UGC item, which is something that a player creates in-game and wants to upload to Steam Workshop, is technically (from Steam's perspective) a folder containing files that you put in. In the case of ATnT, that is a scene file with information about the user-created level which I save using Godot's ResourceSaver class. I will probably describe in another post, how level editing and saving works, but there are some decent tutorials on Youtube

When the level is created with a build-in editor in ATnT, there is a folder (with a unique name) which contains two files: level.tscn and level.data. The second file contains metadata, which I use to store unique Steam item_id, item (in my case, level) title, tags, and other information. This file is created with Godot's ConfigFile class.

Uploading and Updating UGC

The upload happens in two steps: create and update. Creating an item in the Workshop means basically creating a record in some database. The update part is responsible for uploading all the data to Steam servers and (if needed) updating an existing item.

Creating UGC itemCreating is done with just a couple of lines of code:

func upload_level(lvl_id):

    current_item_id = lvl_id

    Steam.createItem(appId, 0)

appId is provided by the current_stats_received callback The lvl_id is a unique identifier which is generated inside a game. It can be a number or string. You can use uuid or prompt your players to name their level. I use a combination of a random number and UNIX timestamp. When the item is created, callback item_created is called. Now you can actually set up all the needed parameters and upload the data for the item.

func _on_item_created(result: int, file_id: int, accept_tos: bool):

    var handler_id = Steam.startItemUpdate(appId, file_id)

    var lvl_id = current_item_id

    var lvl_path = "ABSOLUTE\PATH\TO\ITEM\FOLDER"

    # Access metadata file and read the level title and tags from it

    var metadata:ConfigFile=ConfigFile.new()

    metadata.load("PATH\TO\METADATA\FILE")

    var lvl_title = metadata.get_value("main", "title", "")

    var lvl_tags = []

    # Saving file_id into the metadata file so I can update this item later if needed

    metadata.set_value("steam", "file_id", file_id)

    metadata.save("PATH\TO\METADATA\FILE")

    for tag in ["LIST", "OF", "YOUR", "TAGS"]:

        if metadata.get_value("main", tag, false):

            lvl_tags.append(tag)

    # Setting UGC item title - it will appear in the workshop

    Steam.setItemTitle(handler_id, lvl_title)

    # Setting the path to directory containing the item files

    Steam.setItemContent(handler_id, lvl_path)

    # Setting UGC item tags - they will be visible in the workshop

    Steam.setItemTags(handler_id, lvl_tags)

    # Attaching a preview file is an optional step. The preview file is just a .png image. For example, you can take a screenshot in Godot and save it as file.

    Steam.setItemPreview(handler_id, "ABSOLUTE\PATH\TO\PREVIEW\FILE.PNG")

    # Making item visible in the workshop

    Steam.setItemVisibility(handler_id, 0)

    # Adding workshop metadata that is not visible via web interface. For example, I store the version of the editor.

    Steam.setItemMetadata(handler_id, "OPTIONAL METADATA STRING")

    # Submit item update - Steam will now upload the data

    Steam.submitItemUpdate(handler_id, "CHANGE NOTE")

    current_item_id = null

    current_file_id = file_id

    # You will need this file_id if you wish to monitor the progress

If I need to update an existing UGC item, I just call the _on_item_created function with the saved (in metadata file) file_id. When the item is updated, the item_updated callback is called.

func _on_item_updated(result: int, accept_tos):

    var item_page_template = "steam://url/CommunityFilePage/%s"

    if accept_tos:

        # Ast the player to accept workshop ToS if needed

        Steam.activateGameOverlayToWebPage(item_page_template % str(current_file_id))

Downloading UGC

First, we need to request a list of UGC items in the workshop.

func get_workshop_levels(page :int= 1, filters :Array= []):

    if current_ugc_query_handler_id > 0:

        # There is already a query in the works, so will just release its handler because we need the new one to work its way.

        Steam.releaseQueryUGCRequest(current_ugc_query_handler_id)

    # This is a quick way to convert YOUR pages into STEAM pages. Steam SDK does not allow you to specify the number of items per page, it always returns up to 50 results. Here, LVLS_PER_PAGE = 10 and STEAM_LVLS_PER_PAGE = 50.

    var steam_page = ceil(page * LVLS_PER_PAGE / STEAM_LVLS_PER_PAGE)

    # Creating a query with type 0 - ordered by upvotes for all time.

    current_ugc_query_handler_id = Steam.createQueryAllUGCRequest(0, 0, appId, appId, steam_page)

    # Add filters. In my case, I am exluding tags. filters is just an array of strings.

    for filter in filters:

        Steam.addExcludedTag(current_ugc_query_handler_id, filter)

    # Reduce the number of server calls by relying on local cache (managed by SteamSDK)

    Steam.setAllowCachedResponse(current_ugc_query_handler_id, 30)

    # Finally, send the query

    Steam.sendQueryUGCRequest(current_ugc_query_handler_id)

Then, when the results are received, we can process them and show the list of items to the player.

func _on_ugc_query_completed(handle:int, result:int, results_returned:int, total_matching:int, cached:bool):

    # If the current handler id changed - it means that we requested a list of items again, so we can dismiss these results. 

    if handle != current_ugc_query_handler_id:

        Steam.releaseQueryUGCRequest(handle)

        return

    if result != 1:

        # The query failed. See steam result codes for possible reasons.

        return

    var list_of_results = []

    for item_id in range(results_returned):

        # Get information for each item and (optional) metadata

        var item = Steam.getQueryUGCResult(handle, item_id)

        var md = Steam.getQueryUGCMetadata(handle, item_id)

        item["metadata"] = md

        list_of_results.append(item)

    # Release the query handle to free memory

    Steam.releaseQueryUGCRequest(handle)

    current_ugc_query_handler_id = 0

    # Now we can show the list of results to the player

For the item structure, see GodotSteam documentation

NB: There are also tools to monitor the download progress, but I do not use them. Therefor, this part is omitted. 

In ATnT, I just show a list of results and allow players to download and start playing the level with one click.

Now, we can download the level and start playing.

func download_level(lvl_id):

    if current_loading_level_id==lvl_id:

        # This means that we are already downloading this item. Probably just a second click on a button.

        return false

    elif current_loading_level_id>0 and current_loading_level_id!=lvl_id:

        # We are already downloading another level. I allow this situations to happen but you might decide otherwise.

        pass

    # This function return bool, true if download request was successfully sent

    if Steam.downloadItem(lvl_id, false):

        current_loading_level_id = lvl_id

        # Here you can block user input to prevent send clicks a launch loading animation.

When the item is downloaded, the item_downloaded callback is called.

func _on_item_downloaded(result, file_id, app_id):

    if result != 1:

        # See steam result codes for more details

        print_debug("Download failed %d" % result)

    if file_id != current_loading_level_id:

        # We are expecting another file to download, so we will just skip this one. You can of course allow multiple parallel downloads in you game, if you wish.

        return

    # Getting the information about the downloaded item

    var lvl_info = Steam.getItemInstallInfo(file_id)

    # lvl_info["folder"] will contain a folder with item files

    # In my case, these are level.tscn and level.data

    # Reset the global variable to allow new downloads to happen

    current_loading_level_id = -1

    # You can stop tracking for all items just in case there is still an item in use

    Steam.stopPlaytimeTrackingForAllItems()

    # Start tracking playtime for the downloaded item

    Steam.startPlaytimeTracking([file_id])

    # Lauch the downloaded level

    SomeFunctionToLauchTheLevel(lvl_info["folder"])

NB: There is an issue with the workshop which I encountered. In my case, the Steam.getQueryUGCResult function call failed if the workshop was not visible to everyone. I suspect it happened because the game was not released yet. I was not able to locate and fix this.

Voting

Voting up and down is very simple.

func upvote_level(file_id:int):

    if !checkSteam():

        return false

    Steam.setUserItemVote(file_id, true)


func downvote_level(file_id:int):

    if !checkSteam():

        return false

    Steam.setUserItemVote(file_id, false)

Here, I left the checkSteam function calls. Essentially, it checks that the Steam is running and that the player owns the game. I am not sure if these checks can actually protect you from piracy; I just leave them in case. In other examples, I removed these calls to make the code less wordy. Otherwise, these checks are the first thing I do in almost every function. 

Here is the whole SteamIntegration.gd file.