Devlog 02: the transport underneath
Wait, it's all one MultiplayerPeer? Always has been.
Project SCRAM has real Steam P2P multiplayer now. If you follow along on Bluesky you’ve already seen two clients in the same session over Steam’s relay, shooting frantically at a wave of enemies, which is the part that’s fun to show off. Today I wanted to write a bit about the supporting netcode that makes multiplayer testing so much easier.
The game is co-op by design, which means most of what can break only breaks when two people are in the same session at the same time. The bugs I most need to find are, naturally, the ones that are the biggest pain to reproduce.
The release plan is Steam P2P. Players connect through Steam’s relay, so there’s no port forwarding and no “works on my LAN, dies over the internet” surprise. Great for players. Not so great for me when the ritual is: launch Steam, dig out a second account, run a second copy signed into it, invite myself, wait. Every single time. Do that enough evenings in a row and you quietly stop testing multiplayer at all, and co-op rots while you aren’t looking.
What I wanted was dumber and better. One command, as many windows as I want on this machine, all running the code I just changed, talking to each other, with Steam nowhere in sight. And I wanted it without keeping some second, weird, dev-only version of the game alive next to the real one.
Godot doesn’t care what the wire is
The reason any of this works is one of my favorite things about Godot. The high-level multiplayer honestly doesn’t care how the bytes get from one machine to another. RPCs, MultiplayerSynchronizer, MultiplayerSpawner, all of it runs through SceneMultiplayer, and SceneMultiplayer just talks to an abstract MultiplayerPeer. Give it a peer, any peer, and everything above it works exactly the same.
Godot ships a couple of peers already. ENetMultiplayerPeer is UDP over ENet, which is perfect for localhost. OfflineMultiplayerPeer is a fake one-peer network for solo play. There isn’t a Steam one in the box, but here’s the thing I wish I’d known a lot sooner: you can write your own by extending MultiplayerPeerExtension. So that’s three ways to move packets, and as long as I never hardcode which one is running, I write the game once and it runs on all three.
So I gave myself one rule. No gameplay code is ever allowed to know what the transport is. There’s exactly one place a peer gets created, and it sits behind an interface.
The seam
public interface IMultiplayerTransport
{
TransportKind Kind { get; }
string TransportLogName { get; }
bool IsAvailable { get; }
MultiplayerPeer CreatePeer();
Error StartHost(MultiplayerPeer peer, ushort port);
Error StartClient(MultiplayerPeer peer, string address, ushort port);
void Close(MultiplayerPeer peer);
}Simply four methods that matter. Make a peer, start it hosting, start it connecting, close it. Every transport is one small class that implements those. ENet is the whole idea in miniature, and it’s about as boring as you’d hope:
public sealed class EnetTransport : IMultiplayerTransport
{
public const ushort DefaultPort = 7777;
public TransportKind Kind => TransportKind.Enet;
public string TransportLogName => "enet";
public bool IsAvailable => true;
public MultiplayerPeer CreatePeer() => new ENetMultiplayerPeer();
public Error StartHost(MultiplayerPeer peer, ushort port)
{
if (peer is not ENetMultiplayerPeer enet) return Error.InvalidParameter;
return enet.CreateServer(port);
}
public Error StartClient(MultiplayerPeer peer, string address, ushort port)
{
if (peer is not ENetMultiplayerPeer enet) return Error.InvalidParameter;
return enet.CreateClient(address, port);
}
public void Close(MultiplayerPeer peer) => peer.Close();
}Steam’s version is the same shape. The one real difference is that its StartClient gets a 64-bit SteamID instead of an IP, so it reads the address string as a SteamID and dials the relay with that. And solo gets its own transport, which sounds like overkill right up until you see it:
public sealed class OfflineTransport : IMultiplayerTransport
{
public TransportKind Kind => TransportKind.Offline;
public string TransportLogName => "offline";
public bool IsAvailable => true;
public MultiplayerPeer CreatePeer() => new OfflineMultiplayerPeer();
public Error StartHost(MultiplayerPeer peer, ushort port) =>
peer is OfflineMultiplayerPeer ? Error.Ok : Error.InvalidParameter;
public Error StartClient(MultiplayerPeer peer, string address, ushort port) =>
Error.Unavailable; // solo never joins anything
public void Close(MultiplayerPeer peer) => peer.Close();
}OfflineMultiplayerPeer reports itself as a connected server, peer id 1, with no socket underneath. So a solo game is a real session with a roster of one, and every host-authoritative path (spawning, loot, revives) runs exactly the way it does in co-op. There’s no if (singleplayer) branch anywhere in the game, because from the game’s point of view singleplayer doesn’t exist. That one decision has deleted a whole class of bug I used to write without thinking.
Picking one
Which transport a session actually uses comes down to a couple of pure functions, and I kept them pure on purpose:
public static TransportKind DefaultKindFor(bool noSteamBypass) =>
noSteamBypass ? TransportKind.Enet : TransportKind.Steam;
public static TransportKind Resolve(
TransportKind? launchOverride, TransportKind? preference, TransportKind defaultKind)
=> launchOverride ?? preference ?? defaultKind;Precedence is launch flag, then the dev-menu toggle, then the build default. The default is Steam. Pass --no-steam and it drops to ENet, which is also what the headless test runs use. Because it’s a plain function with no side effects, it’s easy to unit test, and I never have to wonder at runtime which transport got picked or why. That last part matters more than it sounds when you’re staring at a log trying to work out why a client won’t connect.
NetworkManager takes that choice, makes the peer, and hands it to Godot. Stripped of its error handling, the entire handoff is this:
private IMultiplayerTransport SelectTransport()
{
var launchOverride = TransportSelection.ParseKind(DevLaunchFlags.TransportArgument);
var defaultKind = TransportSelection.DefaultKindFor(
SteamManager.Instance?.DevNoSteamBypass == true);
var kind = TransportSelection.Resolve(launchOverride, PreferredTransport, defaultKind);
return kind == TransportKind.Steam ? new SteamTransport() : new EnetTransport();
}
// StartHostSession / JoinSession then do the same three steps:
// _transport = SelectTransport();
// _peer = _transport.CreatePeer();
// _transport.StartHost(_peer, port); // or StartClient(_peer, address, port)
// and finally attach the peer to the existing SceneMultiplayer:
GetTree().GetMultiplayer().MultiplayerPeer = _peer;That last line is the whole trick, and it’s a little anticlimactic. Everything above it, every RPC and synchronizer in the game, gets handed the peer and never once asks what it is. The SceneMultiplayer is never swapped out. Only the peer inside it changes.
Two windows
With the seam in place, the dev loop I actually wanted turned into a tiny script. Build once so both instances load the same fresh code, then open two runtime windows with --no-steam:
# Build once so both instances load the same fresh assembly.
& dotnet build (Join-Path $root 'ProjectScram.csproj') -v quiet -clp:ErrorsOnly
# Two runtime windows side by side, both --no-steam (ENet, not Steam).
$common = @('--path', $root, '--no-steam', '--resolution', '940x680')
Start-Process $GodotPath -ArgumentList ($common + @('--position', '20,40'))
Start-Sleep -Milliseconds 800
Start-Process $GodotPath -ArgumentList ($common + @('--position', '980,40'))Godot will happily run a pile of runtime instances of the same project at once. Two windows come up side by side. I click Host in one, Join 127.0.0.1 in the other, and they drop into the same Hub. No Steam, no second account, no invite dance. When I want the real thing, I drop --no-steam and the exact same build runs over the Steam relay instead.
The part that isn’t free
ENet and Offline peers come with the engine. Steam doesn’t, so SteamMultiplayerPeer is mine to write: a MultiplayerPeerExtension sitting on top of Facepunch.Steamworks, which is a plain C# binding to the Steamworks SDK.
I should be upfront that this was a choice, not a requirement. GodotSteam would have handed me a working peer and saved me a bit of this process. It’s a GDExtension, though, and I wanted the Steam layer to be plain C# I could step through in the debugger like the rest of the game, with no native extension to compile into the engine. So I wrote my own. If that doesn’t matter to you, GodotSteam is the shorter road and I won’t try to talk you off it.
Extending MultiplayerPeerExtension means filling in the twenty-odd methods Godot calls on a peer: what’s your unique id, put this packet on the wire, how many are waiting, poll the socket, and so on down the list. Most of it is mechanical plumbing you can write half-asleep. Two things weren’t.
Peer ids
Godot identifies peers with small signed integers, and the host is always peer 1. Steam identifies you with a 64-bit SteamID that has nothing to do with Godot’s numbering. Something in the middle has to hand out the little ids and get both ends to agree on them.
The handshake I landed on: the server is 1. A joining client rolls its own id and sends it as the very first message on a fresh connection, four raw bytes and nothing else. The server writes down the SteamID-to-id mapping and answers with its own id so the client can file the host away as peer 1. From then on, everything on that connection is opaque Godot payload.
The only fiddly part is telling that opening handshake apart from a normal packet, and I like the answer because it needs no header byte at all. It’s just position:
private void HandleMessage(ulong steamId, Connection connection, IntPtr data, int size)
{
var payload = new byte[size];
if (size > 0) Marshal.Copy(data, payload, 0, size);
if (!_bySteamId.TryGetValue(steamId, out var peer))
{
// First message from an untracked SteamID is the peer-id handshake.
RegisterConnection(steamId, connection);
_bySteamId.TryGetValue(steamId, out peer);
}
if (peer is not null && peer.PeerId == -1)
{
CompleteHandshake(peer, payload);
return;
}
_incoming.Enqueue(new IncomingPacket(payload, steamId, reliable: true));
}
private void CompleteHandshake(SteamPeer peer, byte[] payload)
{
var announcedPeerId = BitConverter.ToInt32(payload, 0);
peer.PeerId = announcedPeerId;
_byPeerId[announcedPeerId] = peer;
if (_role == Role.Server)
SendPeerIdHandshakeTo(peer); // reply with our id (1) so the client can map us
else
_status = ConnectionStatus.Connected;
EmitSignal(SignalName.PeerConnected, announcedPeerId);
}If a message turns up from a SteamID I haven’t mapped to a peer id yet, it can only be that connection’s opening handshake, so I read the first four bytes as the id. Every message after that has a sender I already know, and it goes straight into Godot’s packet queue. No magic prefix, no version field, just “have I seen you before.”
The native library
The other one had nothing to do with networking at all. Facepunch is a thin wrapper over steam_api64.dll, and in an exported build the runtime simply could not find that dll on its own. If your exported Steam build dies on startup hunting for steam_api64, this is very likely why. The fix is to tell .NET where to look before anything touches Steam:
public static void RegisterDllImportResolver()
{
NativeLibrary.SetDllImportResolver(typeof(SteamClient).Assembly, ResolveSteamApi);
}
private static IntPtr ResolveSteamApi(string name, Assembly assembly, DllImportSearchPath? path)
{
if (!string.Equals(name, "steam_api64", StringComparison.OrdinalIgnoreCase))
return IntPtr.Zero;
var dllPath = Path.Combine(AppContext.BaseDirectory, "steam_api64.dll");
return File.Exists(dllPath) ? NativeLibrary.Load(dllPath) : IntPtr.Zero;
}Register a resolver on the Steamworks assembly, point it at the folder the game runs from, and that’s it. Five lines. They took an evening to track down, which is about the going rate for native interop.
The payoff
None of the gameplay code changed for any of this, and that was the entire point. The same session logic that runs when two strangers meet over Steam runs when two windows on my desk meet over ENet, and when I’m alone in a solo game talking to a peer that isn’t even a real socket. A co-op bug that used to cost me Steam, a second account, and a small part of my sanity now costs me one command and two clicks.
Thanks for reading. The parts that are actually fun to look at go up on Bluesky as I make them. See you out there.
// end of log · return to feed