top of page
  • Writer's pictureKarp Paul

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

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.

var current_item_id := 0

func _ready():
	Steam.connect("steamworks_error", self, "_log_error", [], CONNECT_PERSIST)
	Steam.connect("current_stats_received", self, "_steam_Stats_Ready", [], CONNECT_PERSIST)
	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.steamInit()
	Steam.requestCurrentStats()

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


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.


ATnT UGC item folder contents

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.


Metadata file

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 item

Creating 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 documentation.

NB: There are also tools to monitor the download progress, but I do not use them. Thus, 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.

These are Spacewar workshop items.

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 am currently trying to investigate. In my case, the Steam.getQueryUGCResult function call fails if the workshop is not visible to everyone. I suspect it happens because the game is not released yet. If there are any news, I will update the post.

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.


The full example

Here is the whole SteamIntegration.gd file.

extends Node

var appId = 0

const LVLS_PER_PAGE = 10.0
const STEAM_LVLS_PER_PAGE = 50.0

var is_initialized :bool= false

var user_id : int

var current_item_id
var current_file_id
var current_ugc_query_handler_id:int=-1
var current_loading_level_id:int=-1


func _ready():
	Steam.connect("steamworks_error", self, "_log_error", [], CONNECT_PERSIST)
	Steam.connect("current_stats_received", self, "_steam_Stats_Ready", [], CONNECT_PERSIST)
	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.steamInit()
	Steam.requestCurrentStats()


func _process(delta):
	Steam.run_callbacks()


func _log_error(err_signal:String, err_msg:String):
	print_debug("Error with signal: %s" % err_signal)
	print_debug(err_msg)


func checkSteam() -> bool:
	if !Steam.isSteamRunning():
		print_debug("Steam is not running")
		return false
	if !Steam.isSubscribed():
		print_debug("Not subscribed / Ownership is not confirmed")
		return false
	return true


func _steam_Stats_Ready(game: int, result: int, user: int) -> void:
	appId = game
	is_initialized = true


func unlock_achievement(achievent_name):
	if !checkSteam():
		return
	Steam.setAchievement(achievent_name)
	Steam.storeStats()


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)


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.


func upload_level(lvl_id):
	current_item_id = lvl_id
	Steam.createItem(appId, 0)


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)


func open_item_page(file_id):
	Steam.activateGameOverlayToWebPage("steam://url/CommunityFilePage/%s" % str(file_id))


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


func _on_item_updated(result: int, accept_tos):
	var item_page_template = "steam://url/CommunityFilePage/%s"
	if accept_tos:
		Steam.activateGameOverlayToWebPage(item_page_template % str(current_file_id))


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


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"])


72 views0 comments

Recent Posts

See All
bottom of page