---
title: "Steam multiplayer"
description: "Use Steam lobbies, server discovery, authentication, and networking."
seo:
  noindex: true
---

{/* Copyright (c) 2026 Betide Studio. All Rights Reserved. */}

Complete [Steam setup](/integrations/steam/setup) and test with two signed-in accounts. For players from several stores, use the [Crossplay lobby](/crossplay/lobbies) or [session](/crossplay/sessions) API and choose a transport all players can use.

## Create a Steam lobby

**Blueprint**

<Accordion>
  <AccordionItem title="Create a Steam lobby">
<BlueprintViewer title="Create a Steam lobby" src="/blueprints/crossplay-steam-create-lobby.txt" />
  </AccordionItem>
  <AccordionItem title="Find Steam lobbies">
<BlueprintViewer title="Find Steam lobbies" src="/blueprints/crossplay-steam-find-lobbies.txt" />
  </AccordionItem>
  <AccordionItem title="Join a selected lobby">
<BlueprintViewer title="Join a selected lobby" src="/blueprints/crossplay-steam-join-lobby.txt" />
  </AccordionItem>
  <AccordionItem title="Leave a Steam lobby">
<BlueprintViewer title="Leave a Steam lobby" src="/blueprints/crossplay-steam-destroy-lobby.txt" />
  </AccordionItem>
</Accordion>

**C++**

Game module dependencies: `SIKCore`, `SteamIntegrationShared`, `BetideCore`, `Engine`.

```cpp
#include "Engine/GameInstance.h"
#include "Interfaces/SIKOnlineServicesFactory.h"
#include "Interfaces/ISIKLobbies.h"

bool CreateSteamLobby(UGameInstance &GameInstance, FSIKAsyncCallback<FSIKLobby> Completion)
{
    const auto Services = FSIKOnlineServicesFactory::Get(GameInstance.GetWorld());
    if (!Services)
        return false;
    const auto Lobbies = Services->GetLobbiesInterface();
    if (!Lobbies)
        return false;
    FSIKLobbySettings Settings;
    Settings.MaxMembers = 4;
    Settings.LobbyType = ESIKLobbyType::Public;
    Settings.Attributes.Add(TEXT("Mode"), FSIKAttribute::String(TEXT("Coop")));
    Lobbies->CreateLobby(0, TEXT("GameLobby"), Settings, MoveTemp(Completion));
    return true;
}

void FindSteamLobbies(ISIKLobbies &Lobbies, FSIKAsyncCallback<FSIKLobbySearchResult> Completion)
{
    FSIKLobbySearchQuery Query;
    Query.MaxResults = 20;
    Query.DistanceFilter = ESIKLobbyDistanceFilter::Worldwide;
    FSIKLobbySearchFilter Filter;
    Filter.Key = TEXT("Mode");
    Filter.Value = TEXT("Coop");
    Filter.Comparison = ESIKLobbyComparison::EqualTo;
    Query.Filters.Add(Filter);
    Lobbies.FindLobbies(0, Query, MoveTemp(Completion));
}

void JoinSteamLobby(ISIKLobbies &Lobbies, const FSIKLobby &Selected,
                    FSIKAsyncCallback<FSIKLobby> Completion)
{
    Lobbies.JoinLobby(0, TEXT("GameLobby"), Selected, MoveTemp(Completion));
}

bool LeaveSteamLobby(ISIKLobbies &Lobbies, FSIKAsyncCallbackVoid Completion)
{
    const FSIKLobby Lobby = Lobbies.GetLobby(TEXT("GameLobby"));
    if (!Lobby.IsValid())
        return false;
    Lobbies.LeaveLobby(0, Lobby.LobbyId, MoveTemp(Completion));
    return true;
}
```

Bind callbacks with `FSIKAsyncCallback<FSIKLobby>::CreateWeakLambda` and inspect the asynchronous result. The function's return value only confirms dispatch.

Keep a stable local name such as `GameLobby`; it is separate from Steam's lobby ID. Create and search use `Mode=Coop`. Pass the selected search result to the join node. Joining establishes membership; your game still needs a running server and travel flow.

**Destroy SIK Lobby** leaves the local user's lobby membership. Steam removes an empty lobby; an owner leaving a populated lobby can transfer ownership. Ownership transfer does not migrate the Unreal world.

## Listen and dedicated sessions

In OSSv2, listen/presence sessions use Steam lobbies so discovery, invites, and game travel share an identity. Dedicated sessions use Steam server-browser entries. Session discovery can return both.

**Blueprint**

<Accordion>
  <AccordionItem title="Host a Steam listen session">
<BlueprintViewer title="Host a Steam listen session" src="/blueprints/crossplay-steam-create-session.txt" />
  </AccordionItem>
  <AccordionItem title="Find Steam sessions">
<BlueprintViewer title="Find Steam sessions" src="/blueprints/crossplay-steam-find-sessions.txt" />
  </AccordionItem>
  <AccordionItem title="Join and travel to a session">
<BlueprintViewer title="Join and travel to a session" src="/blueprints/crossplay-steam-join-session.txt" />
  </AccordionItem>
  <AccordionItem title="Destroy a Steam session">
<BlueprintViewer title="Destroy a Steam session" src="/blueprints/crossplay-steam-destroy-session.txt" />
  </AccordionItem>
</Accordion>

**C++**

```cpp
#include "Engine/GameInstance.h"
#include "Interfaces/SIKOnlineServicesFactory.h"
#include "Interfaces/ISIKSessions.h"

bool CreateSteamSession(UGameInstance &GameInstance, bool bDedicated,
                        FSIKAsyncCallback<FSIKSession> Completion)
{
    const auto Services = FSIKOnlineServicesFactory::Get(GameInstance.GetWorld());
    if (!Services)
        return false;
    const auto Sessions = Services->GetSessionsInterface();
    if (!Sessions)
        return false;
    FSIKSessionSettings Settings;
    Settings.MaxPlayers = 4;
    Settings.bIsDedicatedServer = bDedicated;
    Settings.bUsePresence = !bDedicated;
    Settings.bUseLobbies = !bDedicated;
    Settings.Attributes.Add(TEXT("Mode"), FSIKAttribute::String(TEXT("Coop")));
    Sessions->CreateSessionWithName(0, TEXT("GameSession"), Settings, MoveTemp(Completion));
    return true;
}

void FindSteamSessions(ISIKSessions &Sessions,
                       FSIKAsyncCallback<FSIKSessionSearchResult> Completion)
{
    FSIKSessionSearchQuery Query;
    Query.MaxResults = 20;
    Query.RequiredAttributes.Add(TEXT("Mode"), FSIKAttribute::String(TEXT("Coop")));
    Sessions.FindSessions(0, Query, MoveTemp(Completion));
}

void DestroySteamSession(ISIKSessions &Sessions, FSIKAsyncCallbackVoid Completion)
{
    Sessions.DestroySession(TEXT("GameSession"), MoveTemp(Completion));
}
```

<Accordion>
  <AccordionItem title="Host and join with travel">

```cpp
#include "Kismet/GameplayStatics.h"

DEFINE_LOG_CATEGORY_STATIC(LogSteamGameTravel, Log, All);

bool HostSteamGame(UGameInstance &GameInstance, FName MapName)
{
    if (MapName.IsNone())
        return false;
    const TWeakObjectPtr<UGameInstance> WeakGameInstance(&GameInstance);
    return CreateSteamSession(
        GameInstance, false,
        FSIKAsyncCallback<FSIKSession>::CreateWeakLambda(
            &GameInstance, [WeakGameInstance, MapName](const TSIKAsyncResult<FSIKSession> &Result) {
                if (!WeakGameInstance.IsValid())
                    return;
                if (!Result.IsSuccess())
                {
                    UE_LOG(LogSteamGameTravel, Warning, TEXT("%s"), *Result.GetError().Message);
                    return;
                }
                UGameplayStatics::OpenLevel(WeakGameInstance.Get(), MapName, true, TEXT("listen"));
            }));
}

bool JoinSteamGame(UGameInstance &GameInstance, const FSIKSession &Selected)
{
    const auto Services = FSIKOnlineServicesFactory::Get(GameInstance.GetWorld());
    const auto Sessions = Services ? Services->GetSessionsInterface() : nullptr;
    if (!Sessions)
        return false;
    const TWeakObjectPtr<UGameInstance> WeakGameInstance(&GameInstance);
    Sessions->JoinSessionByIdWithName(
        0, Selected.SessionId, TEXT("GameSession"),
        FSIKAsyncCallbackVoid::CreateWeakLambda(
            &GameInstance, [WeakGameInstance, Sessions](const TSIKAsyncResult<void> &Result) {
                if (!WeakGameInstance.IsValid())
                    return;
                if (!Result.IsSuccess())
                {
                    UE_LOG(LogSteamGameTravel, Warning, TEXT("%s"), *Result.GetError().Message);
                    return;
                }
                const FString Address = Sessions->GetConnectionString(TEXT("GameSession"));
                if (Address.IsEmpty())
                {
                    UE_LOG(LogSteamGameTravel, Warning,
                           TEXT("Joined session has no travel address"));
                    return;
                }
                UGameplayStatics::OpenLevel(WeakGameInstance.Get(), FName(*Address), true);
            }));
    return true;
}
```

  </AccordionItem>
</Accordion>

Keep the travel examples in a GameInstance Blueprint and pass a packaged map name to the host. Joining returns an address; **Open Level (by Name)** starts travel. Session success does not confirm that travel finished.

Create dedicated sessions on the server. Match the advertised address and port to its reachable endpoint. A server-browser entry does not prove the game port is reachable.

Use Steam sockets for Steam-ID connections or IP for an IP/port endpoint, following [transport settings](/integrations/steam/setup). A Steam lobby alone cannot connect a non-Steam client to a Steam-only transport. Shared hosting flows: [listen server](/crossplay/listen-server), [dedicated server](/crossplay/dedicated-server).

## Lobby data and chat

**Blueprint**

<Accordion>
  <AccordionItem title="Set the party mode">
<BlueprintViewer title="Set the party mode" src="/blueprints/crossplay-steam-lobby-data.txt" />
  </AccordionItem>
  <AccordionItem title="Set member ready state">
<BlueprintViewer title="Set member ready state" src="/blueprints/crossplay-steam-member-data.txt" />
  </AccordionItem>
  <AccordionItem title="Send a party message">
<BlueprintViewer title="Send a party message" src="/blueprints/crossplay-steam-send-chat.txt" />
  </AccordionItem>
  <AccordionItem title="Receive party messages">
<BlueprintViewer title="Receive party messages" src="/blueprints/crossplay-steam-receive-chat.txt" />
  </AccordionItem>
</Accordion>

**C++**

With `ISIKLobbies` from the lobby example, use the active lobby ID from `GetLobby(TEXT("GameLobby"))`:

```cpp
void SendSteamLobbyMessage(ISIKLobbies &Lobbies, const FString &LobbyId, const FString &Message,
                           FSIKAsyncCallbackVoid Completion)
{
    Lobbies.SendChatMessage(0, LobbyId, Message, MoveTemp(Completion));
}

void SetSteamPartyMode(ISIKLobbies &Lobbies, const FString &LobbyId, const FString &Mode,
                       FSIKAsyncCallbackVoid Completion)
{
    Lobbies.SetLobbyData(0, LobbyId, TEXT("Mode"), Mode, MoveTemp(Completion));
}

void SetSteamPlayerReady(ISIKLobbies &Lobbies, const FString &LobbyId, bool bReady,
                         FSIKAsyncCallbackVoid Completion)
{
    Lobbies.SetLobbyMemberData(0, LobbyId, TEXT("Ready"), bReady ? TEXT("true") : TEXT("false"),
                               MoveTemp(Completion));
}

DEFINE_LOG_CATEGORY_STATIC(LogSteamPartyMessages, Log, All);

FDelegateHandle WatchSteamMessages(ISIKLobbies &Lobbies, UObject *Listener)
{
    if (!IsValid(Listener))
        return {};
    return Lobbies.OnLobbyChatMessageReceived().AddWeakLambda(
        Listener, [](const FString &LobbyId, const FSIKLobbyChatMessage &Message) {
            UE_LOG(LogSteamPartyMessages, Log, TEXT("%s"), *Message.Message);
        });
}

void StopWatchingSteamMessages(ISIKLobbies &Lobbies, FDelegateHandle &Binding)
{
    Lobbies.OnLobbyChatMessageReceived().Remove(Binding);
    Binding.Reset();
}
```

Store the returned binding and remove it when closing chat; bind once before sending. `SetLobbyData` updates owner-controlled lobby metadata; member data belongs to the local member.

The data and chat nodes act on the lobby named in **Lobby Name**, `GameLobby` by default. Bind chat once before sending and unbind on cleanup. The receive example uses **SIK Matchmaking Subsystem → On Lobby Chat Msg** and **Get SIK Lobby Chat Entry**; its events cover joined Steam lobbies, so route by `Lobby Id` when showing several chats.

Keep searchable metadata small and consistent between builds. Treat messages and member attributes as untrusted player input. Use lobby events to refresh UI rather than repeatedly searching for a lobby you already joined.

<Accordion>
  <AccordionItem title="Invites, ownership, and kicking">

`ISIKLobbies::InviteToLobby` sends an invite; `OnLobbyInviteReceived()` reports incoming invites. Preserve the invited lobby ID and complete login before joining. Rich-presence join requests also need a valid connection or lobby target—see [Crossplay parties and invites](/crossplay/parties-invites).

`PromoteMember` transfers Steam lobby ownership. Steam has no native owner-side lobby kick: `KickMember` writes a reserved marker that the addressed client observes and then leaves. Use server-side admission and removal rules for enforcement against a modified client.

  </AccordionItem>
  <AccordionItem title="Tickets and server authentication">

A Steam ID supplied by a client is not proof of identity. Validate a fresh ticket using the appropriate Steam server or Web API flow before trusting that player. Session tickets and Web API tickets have different consumers; follow [Valve's authentication guide](https://partner.steamgames.com/doc/features/auth).

The native **Get SIK Auth Session Ticket** node returns a ticket handle and bytes. Wait for the ticket response before using it, retain the handle, and call **Cancel SIK Auth Ticket** when finished. The receiver ends the corresponding auth session during disconnect cleanup. Keep publisher Web API keys on the backend.

C++ exposes these calls on `USIK_UserLibrary` in `Nodes/User/SIK_UserLibrary.h` (`SteamIntegrationKit`). Server-side validation lives in `USIK_GameServerLibrary` in `Nodes/GameServer/SIK_GameServerLibrary.h`.

  </AccordionItem>
</Accordion>
