GodotSteam is a C++ module that can be added to the Godot Engine to enable the
programmer to add universal peer to peer multiplayer functionality to their game
in a (relatively) simple fassion. I also chose to use this for my
MultiGodot project, which is itself a
C++ module.
This blog post will use C++ code. It will be less of a tutorial of setting up
GodotSteam (for that you can read the
docs), and more of a method
to easily communicate between two or more peers through packets.
After following the GodotSteam tutorials you should end up with this set of functions:
// CONSTANTS
static const int PACKET_READ_LIMIT = 32;
static const int MAX_MEMBERS = 4; // Possibly increase this in the future if there is a need.
static const int DEFAULT_CHANNEL = 0;
static const int PACKET_SIZE_LIMIT = 1200; // bytes
// NODES
Steam *steam = nullptr;
// PROPERTIES
uint64_t lobby_id = 0;
uint64_t steam_id = 0;
bool is_lobby_owner = false;
// METHODS
void _create_lobby();
void _join_lobby(uint64_t this_lobby_id);
void _get_lobby_members();
void _make_p2p_handshake();
void _send_p2p_packet(uint64_t this_target, Dictionary packet_data, P2PSend custom_send_type, int custom_channel);
void _read_all_p2p_packets(int read_count);
void _read_p2p_packet();
void _leave_lobby();
// SIGNALS
void _on_lobby_created(int connect, uint64_t this_lobby_id);
void _on_lobby_match_list(Array these_lobbies);
void _on_lobby_joined(uint64_t this_lobby_id, int _permissions, bool _locked, int response);
void _on_lobby_chat_update(uint64_t this_lobby_id, uint64_t change_id, uint64_t making_change_id, int chat_state);
void _on_p2p_session_request(uint64_t remote_id);
void _on_p2p_session_connect_fail(uint64_t this_steam_id, int session_error);
These will enable you to create, join, and send packets (represented by Godot's Dictionary types and then converted to bytecode), which can hold pretty much any information except for pointer types. Pointer types are any type that inherits from Object, because all Objects in Godot are heap allocated, and sending a pointer over the internet isn't very useful.
All GDScript functions are registered through the
ClassDB. This class binds a
method pointer
to a String, enabling the programmer to call any registered function using
just a String.
If we want to call a function remotely on another (or all other) peers, we need to
send a message devoid of pointers, including function pointers. Thus, we can utilize
the ClassDB to send a String with the name of the function and values of arguments
over the internet, and then the client can call that function with those arguments
on the other side. In practice:
void _call_func(Node *node, String function_name, Array args, uint64_t custom_target) {
   NodePath path = editor_node_singleton->get_path_to(node);
   auto send_dictionary = Dictionary({
      KeyValue<Variant, Variant>("message", "call_func"),
      KeyValue<Variant, Variant>("path", path),
      KeyValue<Variant, Variant>("function_name", function_name),
      KeyValue<Variant, Variant>("args", args),
   });
   _send_p2p_packet(custom_target, send_dictionary);
}
And on the receiving end, inside the
if (message == "call_func") {
   NodePath path = readable_data["path"];
   String function_name = readable_data["function_name"];
   Array args = readable_data["args"];
   Node *node = editor_node_singleton->get_node_or_null(path);
   if (!node) {
      print_error("Remote function call on null node at path " + String(path));
   }
   node->callv(function_name, args);
}
With just this simple addition we can now send messages back and forth to any peer!
In theory you could extend this to implement the concept of "returning" variables from
functions. This would be very easily done in GDScript because the
Writing multiplayer code can seem hard to wrap your mind around at first, similar in
caliber to
recursive functions
or multithreading.
Really, it's just a matter of abstracting lower level features like packets in ways
that are more similar to the conventional style of programming. Remotely calling methods
on peers is similar to calling methods on objects. This abstracts the hastle of "sending messages"
into a more familiar style.
Similarities between multiplayer and multithreading can also be drawn, especially in the
context of the race condition.
When multiple processes are running, and one process writies to a data while another process
reads or writes that data,
undefined behavior occurs. In the context of threading, this problem is mostly solved with
the Mutex, which makes sure a thread waits for the other to finish writing/reading to the data
before doing the same.
The key difference between multiplayer and multithreading is that each context has its own
individual variables, and so the risk is more that these variables can become out of sync.
When this happens to key parts of the application (say, a Player's position), you'll have a
problem.
One of the solutions to this problem is the concept of "ownership". Each client owns a set
of specific values, and those are the values they can set. All other values can be read of
course, but should not be set by that client. This concept is great for games, but starts to
break down for more complex apps where ownership may not be so clear, such as a document editing
app. To solve problems like this you could:
Although some of this might be helpful, chances are you're still going to run into trouble
doing whatever it is you're doing. When you do run into trouble, stay resilient! That is
how you gain valuable experience, in a way that no LLM could gain experience without spending
millions of dollars of water and electricity on a retrain.
It would be hard to put into words the knowledge I have gained (and still am gaining, you
never stop learning) from my current project, MultiGodot which you can read more about in
this blog post, and maybe in the future a more technical post
about its inner workings. But until then ...
Happy Tuesday, August 22nd, 2025, 18:47 PDT!