Steam Friends-Only and LAN games in Unreal

· Read in about 10 min · (2079 Words)

Unreal Steam(ed) LAN(s)

Last week the time came to finally move MinerMancers multiplayer code over to Steam. We’re a P2P game - we don’t want to run servers, and it’s a co-op game so host advantage isn’t a problem - so we wanted to retain a LAN as an option as well. There were a few wrinkles to doing this, so I thought I’d quickly document what I learned over the last few days, for the benefit of anyone else tackling this.

I did a lot of searches while trying to solve problems and mostly came up empty, or found outdated information, so this is my attempt at a more complete explanation.

Which Plugins To Use

There are, confusingly, multiple systems in Unreal for doing multiplayer - OnlineSubsystem, OnlineServices, Epic Online Services etc. Because I only want to support Steam, I’m keeping this simple and only using OnlineSubsystem, sometimes referred to ass “OSS v1”. It’s the most mature of all the systems and I don’t need any of the multiple-service-hopping systems right now.

To add to the confusion, there are two NetDriver (lower level transport) implementations for Steam, one being newer than the other. You should always use “SteamSocketsNetDriver” - you might see references online to a “SteamNetDriver” but that’s an old version that was written for an old version of the Steamworks SDK.

Annoyingly, the newer SteamSocketsNetDriver has a plugin dependency on the OnlineServices plugin (aka OSS v2), so even though we’re not using OnlineServices, just OnlineSubsystem, that plugin has to be enabled as well. Sigh.

To sum up, we need the following plugins enabled:

  • Online Subsystem
  • Online Subsystem Steam
  • Steam Sockets
  • Steam Shared Module
  • Online Services (just that, no subtypes - this is only here to satisfy SteamSockets, we won’t actually use it)

So go into Edit > Plugins, search for those and check those boxes as needed, then exit. We have some configuration to do next.

I’ve done everything in this article in a source build based on Unreal 5.6, but OnlineSubsystem is stable through to 5.8 at least and I didn’t need to make any source changes.

Configuration

Next we need to configure the engine to use these plugins. Because Steam is specific to certain platforms, it’s best to put this configuration in platform-specific config files (e.g. Config/Windows/WindowsEngine.ini), but if you want you can put it in Config/DefaultEngine.ini for now:

# Steam configuration
[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/SteamSockets.SteamSocketsNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")

[OnlineSubsystem]
DefaultPlatformService=Steam

[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480
bUsesPresence=true
bUseLobbiesIfAvailable=true
bInitServerOnClient=true
bUseSteamNetworking=true
bAllowP2PPacketRelay=true

[/Script/SteamSockets.SteamSocketsNetDriver]
NetConnectionClassName="/Script/SteamSockets.SteamSocketsNetConnection"

[PacketHandlerComponents]
+Components=OnlineSubsystemSteam.SteamAuthComponentModuleInterface

Let’s go through a few of these config sections.

NetDriver configuration

[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/SteamSockets.SteamSocketsNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")

This section tells Unreal to use the SteamSocketsNetDriver which uses the newest version of the Steam multiplayer SDK. I have also set DriverClassNameFallback to the standard IpNetDriver - this just means that if the Steam system fails to init (e.g. because you launched the game with -nosteam, or launched a second instance of the game), it will use the standard OnlineSubsystem LAN backend instead. This isn’t how we’ll do LAN normally, it’s just there as a fallback.

Steam AppId

[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480

AppId “480” is the test app id that anyone can use for simple testing. It’s quite limited, so you should really put your own AppId in there as soon as possible.

Steam Auth Module

[PacketHandlerComponents]
+Components=OnlineSubsystemSteam.SteamAuthComponentModuleInterface

This config tells UE to enforce Steam authorisation, which means you won’t be able to run the game in Shipping mode unless Steam is running, with an account that has the game in its library.

Non-public Sessions

I’m not going to dump all my code for creating and joining sessions here, because I use C++ and I know a lot of people prefer to use Blueprints, usually with the Advanced Sessions plugin. Plus it’s a lot of extremely boring delegate code because so much is async - I’m going to trust that if you’re reading this, you know the bones of it and can figure out how to map this to Advanced Sessions or your own C++ code.

Instead, I’m going to talk about the specific sharp edges that aren’t covered elsewhere. My code snippets will be C++ but they have direct analogues in Advanced Sessions - although see A Note On Testing for why using Blueprints can be problematic for testing / debugging.

The general principle is this:

The host:

  1. Creates a session
  2. Starts the session
  3. Travels to the starting map with at least the listen option

The client:

  1. Finds sessions
  2. Picks one, then joins it
  3. Travels to the map using the connection info

These sequences are covered in many other tutorials, such as the ones for Advanced Sessions, but weirdly none of them or the docs talk about how to deal with friends-only servers, or how to set up LAN servers. So that’s what I’m going to focus on.

There are lots of options to the Create Session function call and they’re all badly documented. Part of the problem is that different subsystems are free to interpret them as they like, so I’ll talk specifically about how the Steam subsystem uses them.

Friends-only Sessions

Hosting a Friends-only Session

So how do we create a friends-only server? You might think that the 2 sets of connection counts in the settings for Create Session, NumPublicConnections and NumPrivateConnections, might matter, but they literally don’t. The Steam subsystem just adds both together when deciding how many connections to support 🙄. So what determines a friends-only session?

It’s solely the option bAllowJoinViaPresenceFriendsOnly. When that’s true, it causes the Steam subsystem to make a friends-only lobby. Here are the settings I use for a friends-only session:

FOnlineSessionSettings Settings;
Settings.bAllowJoinInProgress = true;
Settings.bShouldAdvertise = true; // This is required in Steam BuildLobbyType
Settings.NumPublicConnections = PlayerLimit;
Settings.bAllowJoinViaPresence = true;
Settings.bAllowJoinViaPresenceFriendsOnly = true; // This alone makes it friends-only
Settings.bAllowInvites = true;
Settings.bUseLobbiesIfAvailable = true;
Settings.bUsesPresence = true;

Finding a Friends-only session

You use the FindSessions API to find public sessions, but this does not work for friends. The only way to find a friend session is to ask if a specific friend is running a session, via the FindFriendSession function. Specifically, the one that takes a single friend as argument.

if (auto Sub = Online::GetSubsystem(GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull), NAME_None))
{
  if (Sub->GetSessionInterface()->FindFriendSession(0, *Friend->GetUserId()))
  {
    // Successfully sent an async request

There is a FindFriendSession function in OnlineSubsystem that takes a list of friends, but it’s not implemented 🙄 So we have to do it ourselves by retrieving a list of friends, then iterating over them one at a time, asking each for details of a session they might be hosting. Oh, and this call is asynchronous, and you can’t have more than one FindFriendSession outstanding at once. So it’s a bit fussy to implement. Not hard, just…fussy.

Luckily, there is a way to retrieve a list of friends who are both running the game and hosting a session right now, to cut down the number of friends you actually need to ask for session details on.

if (auto Sub = Online::GetSubsystem(GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull), NAME_None))
{
  if (auto Friends = Sub->GetFriendsInterface())
  {
    // You can't just use Friends->GetFriendsList, that only returns the results of the last query
    // We have to actually run an async query ReadFriendsList
    // List Name is actually a filter! Great docs guys
    Friends->ReadFriendsList(0,
              EFriendsLists::ToString(EFriendsLists::InGameAndSessionPlayers),
              FOnReadFriendsListComplete::CreateUObject(
                this,
                &UMinerSessionSubsystem::ReadFriendsInSessionComplete));

That second parameter is just called “ListName” but if you dig into the code you find you can convert members of the EFriendsLists enum to a string to filter it down to just friends who are both in the current game, and in a session. That narrows it down!

The completion callback doesn’t give you list itself, you have to call GetFriendsList to actually retrieve the friend list, then call (async) FindFriendSession on each of them (although at this point it’s probably not that many). What a faff, it would be much nicer if you could just call FindSessions and ask for just friend sessions, but you can’t. Sigh. Even Advanced Sessions doesn’t do anything to help you here, it just wraps the same APIs for Blueprints.

It’s not that bad once you understand the sequence at least.

LAN Sessions

Steam is pretty smart anyway with P2P LAN games; if you join a friend on the same LAN via Steam, it will directly connect you rather than use the Steam relay like it normally does. So there’s technically no reason to have a specific LAN mode just to save ping times. However, when you connect via Steam you will still need an online connection to be able to find your friend at all, since it’s the online Steam API that’s joining the dots between you, even if you’re on the same LAN it’s the discovery mechanism.

Since MinerMancers is a P2P game I wanted to continue to support games finding each other directly on the LAN, so that if players were offline but visible to each other (playing Steam in offline mode), they could still connect. Look, I’m old enough to remember LAN parties, OK? 😄

In LAN mode the Steam subsystem is still being used, it’s just not using the online API to find people. Instead, the server sets up a ‘Beacon’ which periodically transmits information about the session to the local LAN, which clients can find. The nice thing about still using the Steam subsystem is that you still get Steam player IDs instead of a name generated from the machine name like you do with the Null subsystem.

There is a bIsLANMatch option in the FOnlineSessionSettings, but this isn’t quite enough to make it work on its own. Still, let’s see the settings passed to CreateSession to begin with:

FOnlineSessionSettings Settings;
Settings.bAllowJoinInProgress = true;
Settings.bShouldAdvertise = true; 
Settings.bIsLANMatch = true; // bingo
Settings.bUseLobbiesIfAvailable = false; // Has to match UsesPresence
Settings.bUsesPresence = false;
Settings.NumPublicConnections = PlayerLimit;

This is all you need on the session side, but it won’t work like this. You also need to modify the options to opening the map after the session is started. If this is the callback you’ve assigned to the StartSession function, you need to do something like this to support LAN:

void UMinerSessionSubsystem::StartSessionComplete(FName SessionName, bool bSuccess)
{
	if (bSuccess)
	{
		// Yay, we're hosting. Change to the main game level in listen mode
		const FString Options = bIsLanMode ? "listen?bIsLanMatch" : "listen";
		UGameplayStatics::OpenLevel(this, GameMapName, true, Options);

You always have to pass listen to the call to host a game, but if it’s a LAN match you also need to add bIsLanMatch (it’s not automatically set from the session).

Note: Yes that’s a ? between the two URL parameters listen and bIsLanMatch. You might think it should be a &, since that’s been the standard way to separate multiple URL query parameters since the dawn of the web (a real URL would be protocol://foo?listen&bIsLanMatch). But whoever wrote FURL didn’t read those specs 🤦‍♂️ You’ve no idea how much trouble that simple deviation from proper practice cost me.

A Note On Testing

It’s worth noting that Unreal will only load the Steam-integrated version when launching the game from the Editor in Standalone mode. If you use PIE in a viewport you’ll only get the Null subsystem, not Steam. This makes Blueprint code that interfaces with Steam very hard to debug - that’s why I write mine in C++.

When you write it in C++ you can simply change the target to Development instead of Development Editor and debug it directly. If you choose to write your Steam integration in Blueprints, you’re going to be limited to debugging it via logging alone.

Joining via Presence or Invites

What if you want to join a friend via the Steam UI instead of using the in-game session browser? You could join via:

  1. Accepting an invite
  2. Right-clicking them and selecting Join Game

Luckily, both of these hit the same end point inside your game, namely the OnSessionUserInviteAcceptedDelegate on the Sessions interface. Yes, selecting “Join Game” from the UI is treated as an implicit invite being accepted, which is handy.

If you implement that delegate, you get a callback much like the one from FindSessions except with a single session as a result, and you can join it much like you do after picking one of the results from find.

End

Apologies if that’s a bit of a brain dump, rather than a step-by-step tutorial, but this post is mostly to cover the things I struggled with because I couldn’t find anyone else documenting it anywhere else. Hopefully this will be useful to someone else in the future.