Ali Şahan Yalçın
DocsStep 4: Start the match

Tutorial: a working multiplayer menu

Step 4: Start the match

Send everyone from the pre-match lobby into the actual game, together.

Both players are in a session. Nobody is playing anything yet. This step connects them.

What has to happen

Starting the match is three things in order:

  1. The host stops accepting new players, so nobody drops into a game already running.
  2. The host travels to L_Match and starts listening for connections.
  3. Everyone else finds out where the host is listening, and connects.

SteamForge does the second and third. The first is one node, and it is yours to call because only you know when your match begins.

Wire the Start button

Add an On Clicked event for Button_Start, then:

  1. Add Get SteamForge Sessions. That is the subsystem holding your session.
  2. Drag off it and add Start Match.

Two nodes, and that is the whole button. Start Match reads the map from the session you already created, closes it to new players, travels you there as a listen server, and publishes the address so everyone in the session follows.

WARNING: Do not use Host Session And Travel to start the match. It always creates a new lobby, so everyone who joined your first one is left behind in it and never follows. It is for creating a session, not for starting one you already have.

Compile and save.

Show who is in the lobby

Players gather before you press Start, and until you show them, nobody can tell whether their friend actually made it in.

Add the text block

  1. Open WBP_MainMenu and click Designer.
  2. From the Palette, drag a Text widget into your Vertical Box, underneath the buttons.
  3. With it selected, rename it Text_Players in the Details panel and tick Is Variable.
  4. Still in Details, find Appearance and tick Auto Wrap Text.

Compile.

Add a variable to build the list in

  1. Click Graph.
  2. In the Variables panel on the left, click +.
  3. Name it PlayerNames.
  4. Set its type to String.
  5. Click the icon to the right of the type and change it from a single value to an Array.

Compile.

Make the event

  1. Right click in an empty part of the graph.
  2. Search for Add Custom Event and add it.
  3. Rename it Refresh Players.

Everything in the next section hangs off that event's execution pin.

INFO: A custom event does nothing on its own. It is a piece of graph with a name, and it runs only when something calls it. Nothing calls this one yet, which is what Keeping the list current is for. Expect the list to stay empty until you have done that part.

Build the list

  1. Drag PlayerNames into the graph, choose Get, then drag off it and add Clear. Connect Refresh Players into it. Without this the list grows every time the event runs.
  2. Add Get SteamForge Lobbies, drag off it, and add Get Current Lobby.
  3. Drag off Get Current Lobby and add Get Lobby Members.
  4. Add a For Each Loop after Clear, and connect the members array to its Array pin.
  5. In the loop body, drag off Array Element and add Break Steam Lobby Member.
  6. Drag PlayerNames in again, add Add, and connect Persona Name to its New Item pin. Connect Loop Body into it.

Then from the loop's Completed pin:

  1. Drag PlayerNames in, drag off it, and add Join String Array.
  2. Set its Separator pin to , (a comma and a space).
  3. Drag in Text_Players, add Set Text (Text), and connect the joined string to In Text.

Compile and save.

INFO: Connecting a String into In Text looks wrong because the pin wants Text, not String. Drop the wire on it anyway and Unreal inserts a To Text (String) node for you. It is the same conversion already sitting in your Host and Join graphs.
WARNING: Use a separator you can actually type. A Blueprint string pin is a single line and does not understand \n, so there is no way to type a newline into one. That is why this uses a comma with Auto Wrap Text rather than one name per line. For one per line you need a Vertical Box and a row widget, which is a bigger build than this step.

Mark the host

Each member arrives with everything you need, already resolved:

FieldWhat it is
PlayerTheir Steam ID
Persona NameTheir Steam name, ready to show
Is Local PlayerTrue for whoever is holding this copy
Is OwnerTrue for the host

To label the host, put a Branch inside the loop on Is Owner. On True, Append (host) to Persona Name and add that. On False, add Persona Name unchanged.

There is no second lookup anywhere here. Steam gives the names back with the member list.

Keeping the list current

Refresh Players needs something to call it, and in this menu one thing does: the lobby changing.

  1. Find Event Construct in the graph. If it is not there, right click and add it.
  2. Add Get SteamForge Lobbies.
  3. Drag off it, search for On Lobby Member Changed, and choose Assign On Lobby Member Changed. That places two nodes: the assign call, and a red bound event node wired into it.
  4. Connect Event Construct into the assign node.
  5. From the red bound event node, right click, type Refresh Players, and place the call node. Connect it.

Compile and save.

INFO: To call a custom event, right click in the graph and type its name. It places a call node with one execution pin in and one out, which is a different node from the event itself.

That is the whole thing. Entering a lobby is a member change, so hosting or joining fires the event and fills the list, for the player who did it as well as for everyone already there.

You do not need a second Refresh Players on Event Construct. The menu is the first thing that loads, so you are not in a lobby yet, and it would read an empty lobby and set the text to the empty string it already holds.

INFO: Add one only if you build a path back to this menu that leaves the session intact, such as returning from a match or accepting an invite that joins before the menu exists. Put it after the assign node when you do. Reading state before subscribing loses anything that happens in the gap, which is the general rule from Blueprint basics.

Compile and save.

INFO: Assign is what binds it. An On Lobby Member Changed node you add on its own never fires, because nothing subscribed it. This is the same pattern as the button On Clicked events.

The bound event hands you a Change value: Entered, Left, Disconnected, Kicked, Banned or Unknown. You can ignore it and rebuild the whole list every time, which is what most games do and what the steps above assume.

Check it

With two clients: host on one, join on the other, and both should show two names. Leave on the second and the host's list should drop back to one within a moment.

INFO: For a "3 / 4" counter, use Get Lobby Member Count with the same lobby, against Max Players from your session settings. Lobby players and the host covers per player data like ready flags and team choices.

Show it only to the host

Every player has a Start button and only one of them can use it. Is Session Host fixes that, and it returns false both for a client and for someone not in a session at all, so one node covers every case.

Bind it to the button's visibility rather than setting it from events, and it keeps itself right without you remembering to update it:

  1. In Designer, select Button_Start.
  2. In Details, find Behavior > Visibility, click Bind, then Create Binding.
  3. In the function it creates, add Get SteamForge Sessions and drag off it to Is Session Host.
  4. Add a Select node, feed the bool into its Index pin, set True to Visible and False to Collapsed, and wire it to Return Value.

Collapsed gives up its space in the layout, Hidden keeps it. For a button in a list, Collapsed is usually what you want.

INFO: This is presentation only, and that is the right way round. Start Match already refuses for anyone who is not the host, so the button being visible would be untidy rather than dangerous. Never let a hidden button be the only thing enforcing a rule.

What happens when you click it

On the host: the level changes to L_Match. The menu widget is destroyed along with the old level, which is expected. The host is now a listen server.

As soon as the map finishes loading, SteamForge publishes the address the host is listening on into the Steam lobby.

On every other player: Steam tells them an address appeared, and they travel to it automatically, arriving in L_Match connected to the host.

You do not wire any of that second half. It is the reason the session layer exists.

Run it

With two players and mock mode still set up from the last page:

  1. Window 1: Host.
  2. Window 2: Find, select, Join.
  3. Window 1: Start.

Both windows should end up in L_Match.

Making players appear in the match

Arriving in the level and having a body there are two different things. Whether you see anything is decided by L_Match's Game Mode, which you have not set.

You did not give L_Match a GameMode Override, so it falls back to the project default in Project Settings > Maps & Modes. That is usually fine, and what it spawns depends on the project:

Project defaultWhat each player gets
Third Person or similar templateThe template character. Both players visible.
An empty projectA Default Pawn, a floating sphere you can fly around. Correct, if plain.
A Game Mode with Default Pawn Class set to NoneNothing. You arrive with no body and see a black screen.

The third row is the one that looks broken but is not. If both windows reach L_Match and you see nothing, give L_Match its own GameMode Override in World Settings, with a Default Pawn Class and a Player Start placed in the level.

INFO: The Game Mode runs on the host only, and the host spawns a pawn for every player who connects. So a missing pawn is one setting on one machine, not something each player has to fix.

Reading the log when it goes wrong

Filter the Output Log for LogSteamForge. The host prints two lines, in this order. First, that it accepted the request and where it is going:

code
1
LogSteamForge: Starting match for session 109775..., travelling to L_Match?listen

Then, once the map is up and a port is bound, the address joiners need:

code
1
LogSteamForge: Published session 109775... at 192.168.1.33:7777. Joiners can travel now.

The joiner prints one line when it acts on that, from whichever route reached it first:

code
1
2
LogSteamForge: Host started the match; following them to the server.
LogSteamForge: Found the host's server at 192.168.1.33:7777 while waiting. Following them.

Read them in order. The first one missing is the step that failed:

Missing lineWhat it means
No Starting match lineStart Match refused. Not in a session, not the host, or the session published no map name.
Starting match but no PublishedThe listen server never came up. Wrong Map Name, or the map is missing from a packaged build. After about ten seconds the host logs that it gave up.
Published but nothing on the joinerThat client was not in the session, or joined with Travel unticked.
Both sides logged, but the joiner never arrivesA network problem rather than a Steam one. See below.
WARNING: You will see none of these in a Shipping build. Shipping compiles logging out, so every line above disappears and a failure looks like a button that does nothing. Package Development while you are getting this working.
INFO: The host keeps trying to publish for about ten seconds, because a map finishing loading and a port being bound are not the same moment. Joiners watch for the address on a timer as well as on Steam's callback, so a joiner who arrived before the host started still gets there. If ten seconds pass with no address, the host logs that it gave up and why.

Playing over the internet

Everything so far works on one machine or one network and needs no setup. Over the internet it will not, and the reason is worth understanding before you try.

The host publishes the address of the machine it is running on, which is a local address like 192.168.1.33. On your friend's computer that address means *their* network. There is nothing there. The join is not refused, it simply never connects, so it looks like the Start button did nothing for them.

Two ways out, and only one of them is realistic for players:

ApproachWhat it costs
Direct IPThe host needs a public address and port 7777 forwarded on their router. Fine for you, not something to ask players for.
Steam relayNothing per player. Valve carries the traffic, players connect by Steam ID, no ports, no addresses.

Setting up Steam relay

SteamForge already publishes the host's Steam ID next to the address, and travels to it instead of the IP as soon as it sees a Steam net driver configured. What it cannot do is choose your networking for you, so this part is project configuration.

Enable two plugins in Edit > Plugins, or in your .uproject:

code
1
2
OnlineSubsystemSteam
SteamSockets

Then add this to Config/DefaultEngine.ini:

code
1
2
3
4
5
6
7
8
9
10
11
12
13
[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/SteamSockets.SteamSocketsNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")

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

[OnlineSubsystem]
DefaultPlatformService=Steam

[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480

Set SteamDevAppId to the same App ID you gave SteamForge. Restart the editor and rebuild.

INFO: DriverClassNameFallback is the important line. If Steam is unavailable, connections fall back to plain IP instead of failing, which is what keeps a LAN session and the editor working.
INFO: This adds no dependency to SteamForge. It detects the configured net driver by name and changes how it addresses the host, nothing more. Remove the config and it goes straight back to direct IP.
WARNING: Both players need a build made after this configuration. A client still on the IP driver cannot talk to a host on the Steam driver.

When the match ends

Call Destroy Session on the SteamForge Sessions subsystem, then travel back to L_Menu.

A session you never destroy keeps your slot occupied and keeps appearing in searches until Steam times it out.

You are done

You have a working host, a working browser, a working join, and a match both players end up in. Everything else in these docs builds on this shape.

Where to go next