GodotSteam and SteamMultiplayerPeer

Some time ago I worked on porting a project, which bridged Steam’s internal networking service to Godot’s High-Level Networking, to GDExtension. It was just different enough to warrant a tutorial on its use, part of which has already been posted to YouTube and Github.

Here, I’m going to provide a text-based walkthrough for those who prefer it!

This portion will be top-down, instead of bottom-up. I won’t be walking you through each component as it comes into play here, but rather I will give you a view of the full system from the highest concepts and their justifications, to the most foundational, in that order. For the other direction, in which you might want to be build bottom-up, I suggest reviewing the example source code and watching the video walk-through.

High Level Networking

Godot’s networking API makes life much easier. Alongside extremely low-level controls, for those of us who are getting creative; Godot has numerous high-level features which automate frequent uses. You can have both.

As game developers, we need to understand that we are taking multiple programs running on different machines and ensuring that they are, within reason, following similar operational parameters. They may not be the same, but they look close enough to provide the illusion of it. Doing this has required a few tricks.

First off, we need to have a singular status as a network peer in a group. Godot provides this with multiplayer.multiplayer_peer, an object available to any node which keeps track of the network state and allows us to make high-level changes. More importantly, it follows something of a Facade Pattern, so its underlying implementation can change, and use whatever we want!

The provided subclasses of MultiplayerPeer are, at the moment, using ENet, WebSocket, and WebRTC. New ones can be implemented with custom builds or GDExtensions, by extending MultiplayerPeerExtension.

Once we have our peers, we must be able to keep track of the authorization to change a property on the network. As an example, if a player is changing their armament or their position, then that player will ideally have full authority over their position, and everyone, including the host, will update to match it. So, peers can have authority over a property.

In line with that, we must be able to synchronize the network when these properties change. As a subset of that, we need to notify all other peers when a new element is spawned into the scene. Godot provides two high-level nodes, called MultiplayerSynchronizer and MultiplayerSpawner, to handle this for us.

Lastly, not every event in a game can be managed with a simple field synchronization. We also have to make remote procedure calls, or RPCs, on other peers—that is, call a function on a different computer entirely.

Steam

Valve’s Steamworks API also has a long standing reputation for its multiplayer handling, and it’s very easy to produce a game with Godot (or any other engine) and distribute it on the Steam store. So what if we want to take advantage of the functions provided by Steamworks, but also want to use High Level Networking features?

Enter Steam Multiplayer Peer.

Steam Multiplayer Peer assists in the connection between HLN programming, and using Steam as a networking service. While services like ENet will connect peers as sockets—that’s an IP address, and a port number—Steam connects by Steam ID, a 64-bit number associated with every account.

RPCs

The fundamental use of a multiplayer peer is with remote procedure calls, or RPCs. RPCs are functions, annotated to be callable from other peers. The call to the function will literally come from a completely different machine. In fact, you can even flag them as callable only from another machine, to avoid design errors or potential exploits.

Common (but not exhaustive) examples include setting the health of a player, or loading the game world on the server. All players need to agree on the health of a peer.

Let’s look at this from the beginning, and consider a hypothetical function that might change this:

func set_health(player : Player, new_health : int) -> void:
    player.hp = new_health

From a single-player perspective, this would set the hit points field on a player to the new value. Unfortunately, peers are intrinsically out of synchrony, and this might get called on one peer without the others receiving it. It would be better to have it called on a single authority.

This is a two-step process, continued in the next section. The first thing we would do is add the @rpc annotation to the function, so the system knows it will be callable from other machines. (Arbitrary functions are not callable remotely.)

@rpc takes a few parameters to further refine permissions. They include “authority”, which prevents any peer aside from the multiplayer authority from calling, and alternately “any_peer”, which permits anyone including the authority. There’s also “call_remote”, which allows the function to only be called on other peers, and alternately “call_local”, in case the server is also a player (a host). Thirdly, there is “unreliable”, “unreliable_ordered”, and “reliable” which determines the nature of the packet being received by this peer and how disposable and fast it is. Lastly, there’s a channel number, but we will be leaving this as zero for this document.

We haven’t defined our hypothetical context very well, but let’s assume that we accept any peer’s call of this function, including the current machine. We would annotate it, as thus:

@rpc("call_local", "any_peer")
func set_health(player : Player, new_health : int) -> void:
    player.hp = new_health

Importantly, calling it alone isn’t enough to make it an RPC call; we need a special syntax for that. set_health.rpc() is the new RPC format, with parameters passed in to rpc(). If we only want it called on a specific peer ID, we would use the related set_health.rpc_id() and pass the first parameter as the peer ID we wish to call it on.

As long as all functions have the same signature on client and peer, and all involved nodes are on identical node paths, this will work like a charm. But, we have one more job to do here. Note that this function is still only setting health on the local peer, everyone else is immediately going to disagree with it, as they haven’t locally called set_health! This brings us to our next foundation of multiplayer games: synchronizers!

Synchronizers

Godot provides a useful pair of node types, the MultiplayerSynchronizer and MultiplayerSpawner. They use the current multiplayer peer (which we will configure next) to notify all connected non-authority peers of changes to properties.

These synchronizers are designed to keep multiple instances of the same program in line with each other. They require node paths to be exactly the same, and function and field definitions to be identical. This is reasonable for what we’re doing. They do not require anything else.

It is important to note that the update being synchronized should always be done on the respective node’s authority, that is, the peer given authorization to change the node with set_multiplayer_authority. If changes are made on other peers, it won’t throw an error, but it will be overwritten in the next frame.

For our hypothetical hit point example, this would mean that we would need to instance a MultiplayerSynchronizer node on the tree, and add the “hp” property to it in its replication list. We also have the option of synchronizing it every frame, on change, or never (for disabling). Every frame (“always”) is the default, but network bandwidth is a precious, unpredictable, and limited thing; so in this instance we might only synchronize it on changes.

A closely related concept is the MultiplayerSpawner. This node type keeps track of scenes which are being added to the scene tree, and ensures that they are kept synchronized across the network. An example might be a laser blast scene from a star fighter player; every other peer needs to know that the laser blast was instanced. The bomb in our Bomberman-like example is another thing that needs to be spawned globally on all peers.

In some instances, a MultiplayerSpawner can be configured to spawn elements in a specific way, configuration included, with its spawn function. This is a very interesting function from a formatting perspective; it requires a virtual function to be set, called spawn_function, which takes a single parameter of type variant, called data.

The coding ethics get a little awkward here, but if data is an array, you can pass any number of parameters to it. It is also possible to create a class which contains these properties. In our Godot 2-era example code, data was an array, with two entries—a Vector2 position and the peer ID of the player who dropped it, in that order. An actual structure for it, or even a dictionary, would arguably be better, but we’ll tolerate this here for the sake of the example.

So our patched in spawn_function spawns a bomb locally, sets its parameters to those which have been passed through the spawn call, and returns the item. The item will be returned to the infrastructure in spawn, which will then add it to the tree. This makes instantiating elements in the game’s universe on all peers particularly easy to do.

Of course, for any RPCs or RPC-built nodes like MultiplayerSynchronizer and MultiplayerSpawner to work, we first need to set up and connect our multiplayer peer. Otherwise, they won’t know who to sync to, or how.

Multiplayer Peer

Let’s start with a basis of our classic ENet interface, which is usually the first thing people learn. In most cases I create the peer before I set the multiplayer system to use it, so we would begin with that.

var peer : MultiplayerPeer = ENetMultiplayerPeer.new()

From this point forward, peer is a fresh ENet interface. From here, we need to decide which state that the peer should be in. Games, generally speaking, only have a single server. Often they have a host, which, in this context, is a player who is also doing double-duty as the server.

We do need at least one, and usually only one, server. To produce it, one player must call this:

peer.create_server(PORT_NUMBER)

Here, PORT_NUMBER is an integer, in the range of 1024 to 49,151. I usually recommend keeping it above 2000 and making it flexible, in case another program is using it; but that’s not terribly likely if you avoid registered ports.

(While an IP address is something like a physical location of a computer, you can think of a port as the actual software addressee. A decent analogy would be to think of the IP as the physical address on an envelope, and the port as the name of the individual living there. Using the same port for multiple programs has the same issue as more than one person, with the same name, living at the same address.)

Of course, while the multiplayer peer is a Facade pattern, the multiplayer API is a singleton of sorts. The multiplayer API only has one slot for a peer, and it is the peer that your program will use. Now that we’ve established an ENet server, we will need to set the API to use it specifically.

multiplayer.multiplayer_peer = peer

These three lines create a server on a specific machine. However, a server isn’t much good without a client. To create a client, we do things very similarly. We start by creating a new ENetMultiplayerPeer like before, but on the client machine; and we end by setting our used multiplayer peer to the new one; but in the middle, instead of create_server, we call this:

peer.create_client(ADDRESS, PORT_NUMBER)

The address should be the IP of the host, from our machine. It might be on the local network, or it might be on the other side of the world. The local machine, which can easily be both client and server on ENet (making it great for testing), is always at IPv4 127.0.0.1, or is simply called “localhost”. ADDRESS is a string, not an integer. If you’re using an IPv6, you’ll be passing in a sequence of hexadecimal numbers (zero to nine, and A through F) separated by colons.

Once our multiplayer peer is set up, synchronizers, RPCs, and spawners will intrinsically start using it to do their job. But how would we do this over a relay service, like Steam?

The Steam Multiplayer Peer

To use the Steam Multiplayer Peer, three things are necessary. The first is to get into business with Steam, which is relatively easy to do but not instantaneous. Signing up with them requires US$100 per game, which goes entirely to children’s hospitals and exists purely to protect them from getting saturated by shovelware.

However, doing this for the first time requires a number of tax forms and a two-week waiting period. Sometimes they’ll have it done quicker, sure, but that’s not something you should count on. Valve, the owners of Steam, needs to make sure that you are a legitimate entity and there aren’t any embargoes or other red flags on you which would prohibit this.

This usually goes pretty smoothly. Once you are cleared for business, if you are using Godot, you are going to need a few extensions. The most fundamental is GodotSteam, which bridges the Steamworks API to Godot’s high level languages. This is available via the Asset Library in your editor.

Once GodotSteam is installed, you will need an additional package, Steam Multiplayer Peer, which works on top of it. This allows you to use the Steam networking service as a multiplayer peer.

One interesting and important caveat is that Steam does not use sockets on the surface, it uses serial numbers for members and does socket calculations internally. So, in order to test your program over Steam, you will need to have at least two unrestricted accounts. It’s certainly possible to simply buy something cheap on the platform, but new members are temporarily limited in what they can do. It’s often easier to simply use a friend or family member’s account, with their permission.

In a sense, having an additional testing account is the fourth thing you need. Lastly, the Steam client does need to be running.

Steam hides the personal data, inclusive of IP address, behind a useful lobby system. Each game has its own serial number and associated lobby list. Most of this is not directly exposed to Godot, but this is generally a helpful thing as it’s less for us to worry about.

By using Steam’s lobby system interface, it is very easy to create a group of players and synchronize over the network. But, let’s start with running GodotSteam in general, and exposing the Steamworks API.

When you register a slot for releasing a game on Steam, you will receive a game ID, another serial number. On launch, generally in an autoloaded script, you will need to call the steamInit or steamInitEx function. This notifies Steam that you are running this program, and enables its access to associated functions.

If you do not yet have an appropriate game ID for your game, or you are simply experimenting, game 480 (Spacewar) is used for testing. I suggest consistently starting here, and progressing to your own game ID on release. Any connection must be a legitimate Steam ID which you either own or are developing, or it will be rejected by the client.

Most of the Steamworks library uses callbacks, that is, functions which are called when a specific event occurs. It uses a sort of observer-adjacent system. On each frame, perhaps in the same class that called SteamInit[Ex], you will want to call Steam.run_callbacks() to intercept events. I generally put this at the top of the _process function.

Once you have these features going, Steam is largely event driven. There are many things we can do from here, but let’s focus on a lobby system. To create a lobby, in the way of providing a networked game over the Steam matchmaking system, we call Steam.createLobby. Remember that these functions are only available with GodotSteam installed, and if they don’t show up immediately, I suggest restarting your editor.

The createLobby function takes a lobby type parameter (usually Steam.LOBBY_TYPE_PUBLIC or Steam.LOBBY_TYPE_FRIENDS_ONLY, but multiple other options exist and have uses), and a maximum number of peers. The absolute maximum number of concurrent peers in the same lobby, as of the time of this writing, is 250 on Steam.

Once createLobby is called, we get our first callback! This is Steam.lobby_created. We connect this to a function to handle the result. There’s a status code, which is set to an integer and is one on success (make an effort to check against the enumeration, for clarity). There’s also a new lobby ID, another Steam-based serial, which is the identifier for the lobby you just created.

It’s not a bad idea to set up basic elements of your lobby here, with setLobbyData; this allows you to associate elements of a dictionary with your lobby ID, which can be used for pre-game communication.

However, a lobby is not the same as the actual game, it’s just a means of getting there. Players are still fundamentally socket bearers, even if the socket itself is hidden from us. To do this, we create what I might call a Steam-socket. We begin with, you guessed it:

var peer : MultiplayerPeer = SteamMultiplayerPeer.new()

This creates a peer which internally passes all data to the Steam client and allows it to handle networking. We aren’t done yet, though. We also need to create the server, which, in SteamMultiplayerPeer‘s case, is done with the method create_host:

peer.create_host(0, OPTIONS_ARRAY)

This requires some minor explanation. The first parameter is effectively a virtual port; all messages going over it should be using the same one, and the vast majority of the time it is fine to leave it as zero. The second is a standard array, which is often completely empty, of options. These options are pairs of ESteamNetworkingConfigValue integers to their settings. In most cases, this isn’t a concern and an empty array is perfectly fine.

Lastly, never forget to set your singular multiplayer peer to the one you just created.

multiplayer.multiplayer_peer = peer

You’ll note the lack of a Steam ID here. This is because the Steam client is managing that, and is associating all actions with the ID of the person logged in. It does not require (and likely would not allow) the ID of the user creating the host. The client, however, is a different story, as the Steam ID of the host is not known yet.

SteamMultiplayerPeer‘s create_client function is very similar to create_host, that said. Again, it knows the Steam ID of the player connecting. However, it will need the Steam ID of the player we are connecting to. So, let’s walk back a little, to the callbacks again.

Steam.lobby_joined is much like Steam.lobby_created. It takes four parameters instead of two, though. The first is the lobby ID number. The second is an integer for “permissions”, but historically has never been used and is always set to zero. The third is whether the lobby is locked to invited players, as a regular boolean; this should be taken into account in your design. Lastly, we have the response code used to determine whether you have successfully joined the lobby, or what’s known of what went wrong!

First, check your final parameter, the status, against Steam.CHAT_ROOM_ENTER_RESPONSE_SUCCESS. If it’s true, then you are connected to this lobby, and can continue. Take note of any information you want to cache, like the lobby ID, and then get the player ID of the lobby owner. This is the important part, as otherwise, the lobby will not be able to connect us together as a game.

var id := Steam.getLobbyOwner(new_lobby_id)

Once you have that ID, you have what you need to tie your programs together over the Steam network. The peer ID of the player that created the lobby is our server. So, we can fire peer.create_client, for Steam, like so:

peer.create_client(id, 0, OPTIONS_ARRAY)

Once we’ve done this, and succeeded, we have a full synchronization going over the Steam relay service. It will work just as reliably as ENet, but using Steam itself to keep your connection going and clean. Moreover, you will have access to other Steamworks features (if you should choose to use them) such as Steam Input, Workshop, Achievements, Cards, and more.

Summary

To acquire peer-to-peer synchronization over Steam networking, have a Steam partnership, and attach Godot Steam and Steam Multiplayer Peer to your project. Create a lobby over Steam using your host, and then connect other players to it using the ID returned by Steam.getLobbyOwner. Initialize SteamMultiplayerPeer and call create_host and create_client accordingly, then use synchronizers and spawners as you normally would for your game.

Review example code here!

Published by Michael Macha

I'm a game developer for both mobile and PC. My education is in physics, journalism, and neuroscience. Founder and CEO of Frontier Medicine Entertainment, located in the beautiful city of Santa Fe, New Mexico.

One thought on “GodotSteam and SteamMultiplayerPeer

  1. This plugin just saved me around 9 months of development hell. I tested it on the game I am developing right now, which previously had only ENet multiplayer, and the connection with Steam lobbies works like a charm!! No more having to rely on NAT punchthroughs, port forwarding or UPNP. Now my players can just invite each other via Steam and the rest of the code base works exactly as if it was an ENet connection, with Multiplayer Synchronizers, Multiplayer Spawners and RPCs. I had to touch 0 lines of my current code. This is honestly incredible, and I seriously think everyone should know about this possiblity.

    Thank you for developing this, from the bottom of my heart.

    Liked by 1 person

Leave a comment