toamig.com / Plugins / PlayLink Get on Fab

PlayLink

An OSS-agnostic multiplayer social layer for Unreal Engine 5. One API for identity, friends, parties, lobbies, sessions, matchmaking, achievements, stats, and leaderboards, running unchanged across Null, Steam, EOS, and EOSPlus. Drop in, route, ship.

v0.5

Overview

PlayLink is an OSS-agnostic multiplayer social layer for Unreal Engine 5. One API surface covers identity, friends, parties, lobbies, sessions, and invites, and it runs unchanged across the Null, Steam, EOS, and EOSPlus Online Subsystems. The UPlayLinkSubsystem resolves the active backend at runtime, and per-domain Game Instance Subsystems hang off it: UPlayLinkIdentitySubsystem, UPlayLinkFriendsSubsystem, UPlayLinkLobbySubsystem, UPlayLinkSessionSubsystem, and UPlayLinkPartySubsystem.

Each domain exposes BlueprintAssignable dynamic multicast delegates (OnFriendsListUpdated, OnLobbyMemberJoined, OnSessionsFound, etc.) that re-broadcast OSS events as project-friendly types, FPlayLinkUserId, FPlayLinkLobbyHandle, FPlayLinkSessionSearchResult, FPlayLinkPartyMember. There's no Online Subsystem coupling in your project code: you call the PlayLink subsystem, it routes to the active OSS, and you handle the result by event.

Backend Routing

UPlayLinkSubsystem resolves the active EPlayLinkBackend (Null, Steam, EOS, EOSPlus) at startup. Your gameplay code stays OSS-agnostic, switch backends in DefaultEngine.ini, the same calls keep working.

Identity & Friends

Login state, display names, friend lists, and presence, all surfaced through UPlayLinkIdentitySubsystem and UPlayLinkFriendsSubsystem. Steam auto-detects an already-authed client; no explicit dialog.

Lobbies, Sessions, Parties

Three multiplayer domains, one consistent shape per domain: Action_Host*, Action_Find*, Action_Join*, Action_Leave*. Lobbies are presence-true sessions; parties are pluggable via UPlayLinkPartyServiceBase.

Cross-Domain Invites

A single FPlayLinkInvite shape covers lobby, session, and party invites. Inbound dispatch fans out to the right subsystem; outbound senders route through whichever domain you're inviting into.

Pluggable Parties

Parties run on top of an abstract UPlayLinkPartyServiceBase. The default UPlayLinkLobbyPartyService models parties as presence-true lobbies; subclass the base to swap in EOS dedicated parties or any custom backend.

Runtime Dev Panel

The PlayLinkDev developer module ships with cheat exec commands and a UMG debug overlay (WBP_PlayLinkDevWidget). Stripped from shipping builds; works out of the box on first project enable.

Concepts, Party vs Lobby vs Session

The PlayLink API exposes Party, Lobby, and Session as three sibling subsystems with near-identical surface shapes (Action_Host*, Action_Find*, Action_Join*, Action_Leave*). They are not interchangeable. The three solve different problems and only one of them has a network endpoint. Read this section before wiring multiplayer flow on top of PlayLink.

The progression in a real game

flow
Party  ──►  Lobby     ──►  Session   ──►  Gameplay
(group)    (configure)    (match)        (level)

Friends form a party that persists across matches. They enter a lobby to pick mode and map. When ready, the lobby host creates a session and everyone joins it. The host then ServerTravels to the gameplay map; clients ClientTravel along to the host's listen server.

Lobby, metadata bulletin board

Backed by an OSS object (Steam Lobby, EOS Lobby) that stores key/value pairs: name, max players, custom flags (region=eu, mode=ranked), chat. Members read/write metadata and exchange messages. Settings round-trip via OnLobbySettingsChanged + OnUpdateSessionComplete.

A lobby has no IP, no port, no NetDriver. You cannot ClientTravel to a lobby, there is nothing on the wire to connect to. Use lobbies to wait for friends, configure match rules, decide what to play next, and hold pre-game chat.

Party, cross-game social grouping

"I'm queueing with these three people, drag us together wherever we go." Parties span sessions, lobbies, and even sequential matches, they survive a match ending so the same group can re-queue without losing its membership.

Implementation differs per backend. Steam has no native parties; PlayLink ships UPlayLinkLobbyPartyService, a presence-true OSS session named PlayLinkParty that stands in for a party. EOS has a real IOnlineParty service planned as a sibling plugin (PlayLinkEOSParty). Either way the public API stays the same.

A party also has no network endpoint. Parties don't host gameplay, they track who's grouped together so when a match starts, the matchmaker pulls everyone into the same session. Use parties to persist a friend group across multiple matches.

Session, the actual game match

Backed by an OSS session and a UE listen-server NetDriver. A session has an IP:port. JoinSession returns a connect string that APlayerController::ClientTravel hands to the engine's net layer; the client lands on the host. This is the only one of the three where "transport me to the host's level" is a meaningful operation.

When to pick which

ScenarioUse
Show chat, map vote, or mode picker before a match starts Lobby
Keep a squad together across multiple matches Party
Replicate actor state between players in a level Session
Travel everyone to the gameplay map (ServerTravel + ClientTravel) Session

Backend coverage

ConceptSteamEOSNull / LAN
Lobby OSS Session w/ bUsesPresence=true, bUseLobbiesIfAvailable=true EOS Lobby LAN UDP broadcast
Party Stub via UPlayLinkLobbyPartyService IOnlineParty (planned sibling plugin) Stub via lobby fallback
Session OSS Session w/ bShouldAdvertise=true + listen NetDriver EOS Session LAN UDP broadcast
The dev panel reflects this distinction: only the Session card has a Travel button. Asking a lobby or party to travel is a category error, they are coordination state, not transport endpoints.

Getting Started

PlayLink has no per-project setup beyond enabling the plugin and picking your OSS in DefaultEngine.ini. From there every subsystem is a single GetGameInstance()->GetSubsystem<...>() away.

1

Enable the plugin

Open Edit → Plugins, search for PlayLink, enable it, and restart the editor. Add "PlayLink" to your .Build.cs public deps if you'll call PlayLink from C++.

2

Pick a backend

In DefaultEngine.ini set [OnlineSubsystem] DefaultPlatformService=Steam (or EOS, EOSPlus, or leave unset for Null). PlayLink resolves whichever OSS is active at startup, no plugin code change needed when you switch.

3

Verify the router

PIE the project and open the dev panel: press ~ and type PlayLinkOpen. The Backend card surfaces the resolved backend (EPlayLinkBackend::Steam, etc.) and the Identity card shows the local user. If the panel reads Unresolved, your OSS is not configured.

4

Bind the events you need

In your game subsystem or controller, get a per-domain subsystem and subscribe to its delegates. Treat the delegates as your only source of truth, gameplay code never queries the OSS directly.

5

Drive scenarios

Call Action_* functions to host, find, join, or leave. Higher-level helpers like UPlayLinkSessionSubsystem::FindAndJoinAnySession compose the full search-then-join flow into a single call when you don't need fine-grained control.

Minimal C++ wiring

C++
// In your subsystem header
UFUNCTION()
void HandleFriendsListUpdated(int32 LocalUserNum, EPlayLinkResult Result);

UFUNCTION()
void HandleLobbyCreated(EPlayLinkResult Result, FPlayLinkLobbyHandle Handle);
C++
// In Initialize
UGameInstance* GI = GetGameInstance();
UPlayLinkFriendsSubsystem* Friends = GI->GetSubsystem<UPlayLinkFriendsSubsystem>();
UPlayLinkLobbySubsystem*   Lobby   = GI->GetSubsystem<UPlayLinkLobbySubsystem>();

Friends->OnFriendsListUpdated.AddDynamic(this, &UMySocialSubsystem::HandleFriendsListUpdated);
Lobby->OnLobbyCreated.AddDynamic(this, &UMySocialSubsystem::HandleLobbyCreated);

// Refresh friends and host a 4-player lobby
Friends->RefreshFriendsList(0);
Lobby->Action_HostLobby(0, 4, false);

Router & Backends

UPlayLinkSubsystem is the routing layer. It resolves the active IOnlineSubsystem at game-instance init, classifies it into an EPlayLinkBackend, and binds the per-domain subsystems to the matching OSS interfaces. Every PlayLink subsystem holds a weak reference to the resolved OSS, when the OSS drops, the subsystems drop their interface pointers.

BackendResolved WhenNotes
EPlayLinkBackend::Null No DefaultPlatformService set; or NULL explicitly. LAN-style local play. Sessions search the local network only.
EPlayLinkBackend::Steam DefaultPlatformService=Steam with the Steam client running. Auto-detects already-authed client. Friends list filters by App ID.
EPlayLinkBackend::EOS DefaultPlatformService=EOS. Standalone EOS, no Steam piggyback. Login flows depend on configured EOS auth scopes.
EPlayLinkBackend::EOSPlus DefaultPlatformService=EOSPlus. EOS layered over a base OSS (commonly Steam). PlayLink reads identity from EOS while sessions/lobbies route to the configured pair.
EPlayLinkBackend::Unresolved No OSS available, or resolution failed. All Action_* calls return EPlayLinkResult::Failed_NoBackend immediately. Inspect LogPlayLink at startup.

Querying readiness

C++
UPlayLinkSubsystem* Router = GI->GetSubsystem<UPlayLinkSubsystem>();
if (Router->IsReady())
{
    EPlayLinkBackend Backend = Router->GetBackend();
    UE_LOG(LogTemp, Log, TEXT("PlayLink active: %s"),
        *UEnum::GetValueAsString(Backend));
}
The router resolves once per game-instance lifetime. Switching DefaultPlatformService at runtime is not supported, change the ini and restart the project.

Identity

UPlayLinkIdentitySubsystem wraps IOnlineIdentity. It tracks login state per local user, surfaces the platform-agnostic FPlayLinkUserId, and exposes display-name resolution. On Steam the local user is auto-detected from the running client at startup, calling Login() just verifies and broadcasts OnLoginStateChanged. On EOS the call drives the configured auth flow (Account Portal, Persistent Auth, Exchange Code, etc.).

FunctionDescription
Login(int32 LocalUserNum) Drive the platform's login flow. No-op on Steam if the user is already authed. Result fires on OnLoginStateChanged.
Logout(int32 LocalUserNum) Request a logout. On Steam this rarely actually disconnects (Steam owns the session); on EOS it ends the auth session.
IsLoggedIn(int32 LocalUserNum) Returns true if the OSS reports the user as logged in.
GetLocalUser(int32 LocalUserNum) Returns an FPlayLinkUserId wrapping the OSS unique net id.
GetLocalUserDisplayName(int32 LocalUserNum) Returns the display name (gamertag / nickname) reported by the OSS.

Login event

DelegateParametersWhen it fires
OnLoginStateChanged int32 LocalUserNum, EPlayLinkResult Result, FPlayLinkUserId UserId After every Login() / Logout() attempt. Result is Success or one of the Failed_* codes.

Automatic rich-presence writes

Identity subscribes to lobby/session/party lifecycle events and writes the local user's OSS rich presence automatically. The platform's friends list reflects the player's current state ("In Lobby", "In Match", etc.) without project-side glue code.

StateWhen appliedStatus string
InMatch Local player is in a session "In Match"
InLobby Local player is in a lobby (not also in a session) "In Lobby"
Online Default, neither in lobby nor session empty

Override the status string mid-match by calling SetLocalUserPresence(LocalUserNum, State, StatusString) directly, useful for richer presence like "In Arena 4, 12/20". The next lifecycle event will re-apply the auto-state unless auto-writes are disabled.

ini
; DefaultEngine.ini, disable automatic presence writes
[PlayLink]
bAutoWritePresence=False

Falls back gracefully on backends without a presence interface (Null OSS, some EOS configs): the cached state is still readable via GetLocalUserPresence so projects can drive UI off it without OSS support.

Friends

UPlayLinkFriendsSubsystem manages the friends list and presence. Call RefreshFriendsList() to read from the OSS, then read the resolved array via GetFriendsList(). Each entry is an FPlayLinkFriend snapshot with a stable UserId, current DisplayName, an EPlayLinkPresence coarse state, and a bIsPlayingThisGame flag.

PropertyTypeDescription
UserId FPlayLinkUserId OSS-agnostic identifier. Use for invites, profile lookups, and equality checks.
DisplayName FString Gamertag / nickname as reported by the OSS.
RealName FString Real name if exposed by the backend; usually empty.
Presence EPlayLinkPresence Coarse presence: Offline, Online, Away, InMenu, InLobby, InMatch. InMenu / InLobby / InMatch are written automatically by UPlayLinkIdentitySubsystem as the local user moves through lobby/session lifecycle, and read back on friends via their OSS rich-presence status string.
bIsPlayingThisGame bool true when the friend is currently in the same game as the local user.

Events

DelegateParametersWhen it fires
OnFriendsListUpdated int32 LocalUserNum, EPlayLinkResult Result After RefreshFriendsList() resolves. Read the array with GetFriendsList().
OnFriendPresenceChanged FPlayLinkUserId FriendId, EPlayLinkPresence NewPresence Per-friend presence delta as the OSS reports rich presence updates. Re-query GetFriendsList() for the full state.
On Steam the friends list filters to friends who own the same App ID. With Steam's dev App ID 480 (Spacewar) the resolved list is usually empty. Test with a real App ID, or have a friend install Spacewar.

Invites

Invites are unified under FPlayLinkInvite. The struct carries the inviter's FPlayLinkUserId, an EPlayLinkInviteKind (Lobby, Session, or Party), and the opaque OSS payload needed to accept. Inbound invites land on whichever subsystem owns the kind, the friends subsystem broadcasts OnInviteReceived for unified UI handling, and the destination subsystem resolves the accept path.

Sending invites

Each domain has its own send-invite verb on the friends subsystem. Pass the target friend's UserId; PlayLink routes to the active OSS invite interface for that domain.

FunctionDescription
SendLobbyInvite(LocalUserNum, FriendId) Invite a friend to the local user's current lobby.
SendSessionInvite(LocalUserNum, FriendId) Invite a friend to the local user's current gameplay session.
SendPartyInvite(LocalUserNum, FriendId) Invite a friend to the local user's party.

Accepting invites

When OnInviteReceived fires, hand the FPlayLinkInvite back to AcceptInvite(). PlayLink dispatches based on Kind, joining the right lobby, session, or party, and broadcasts the destination's join event when the join completes.

C++
// Bind once
Friends->OnInviteReceived.AddDynamic(this, &UMySocialSubsystem::HandleInvite);

// Handler, accept everything for now; real UI would prompt the user
void UMySocialSubsystem::HandleInvite(FPlayLinkInvite Invite)
{
    Friends->AcceptInvite(0, Invite);
}

Handling failures (project-side)

Every PlayLink async op surfaces an EPlayLinkResult on its completion delegate (OnInviteAccepted, OnSessionJoined, OnLobbyJoined, etc.). The plugin ships a broad classification of failures, enough to drive generic UI flows, but does not enumerate every backend-specific cause.

CodeWhat PlayLink guarantees
SuccessOperation landed; consumer can read result data.
Failed_NoBackendNo OSS resolved (or the OSS interface is unavailable).
Failed_NotLoggedInLocal user is not authenticated.
Failed_NetworkGeneric network failure reported by the OSS.
Failed_TimeoutOperation did not complete within the OSS-defined window.
Failed_AlreadyInStateAlready in a session/lobby/party (or already logged in).
Failed_NotInStateNot currently in the state the op requires (e.g. leaving a session you're not in).
Failed_PermissionDeniedBackend rejected for permissions / privacy / invite rules.
Failed_FullSession or lobby is full.
Failed_CancelledUser-initiated cancel.
Failed_BackendErrorBackend returned an error not mapped to a more specific code, inspect LogPlayLink for the native OSS error.

These cover the shape of failure, enough to show "couldn't join (full)" vs "couldn't join (permission denied)" vs "couldn't join (no network)". Beyond this, the realities of online platforms include many platform-specific failure modes that PlayLink intentionally does not enumerate:

Platform-specific failureWhere to detect it
Version mismatch, client / server NetCL or game build differs UE's FNetworkVersion check; surfaces as NetworkFailure on travel, not on PlayLink's delegates
Friend doesn't own the game, invite sent to a friend who doesn't have the title installed / entitled Steam: filter FPlayLinkFriend::bIsPlayingThisGame before sending; EOS: entitlement check via project's storefront integration
Rate-limited by backend, Steam throttles presence + invite spam, EOS rate-limits writes Maps to Failed_BackendError with backend-specific error string in LogPlayLink
Region restriction / geo-block Maps to Failed_PermissionDenied or Failed_BackendError depending on the backend
Crossplay blocked, friend on different platform + crossplay disabled Project-side: check EPlayLinkBackend of both users before inviting
Account suspended / VAC-banned Maps to Failed_NotLoggedIn on Steam after the platform layer rejects auth
Stale invite, session destroyed before user accepted Maps to Failed_BackendError, destination join broadcasts Failed_NotInState when the session no longer exists

Why this design: every backend names failures differently and adds new failure modes over time (Steam updates, EOS feature additions, console certification requirements). Pinning PlayLink's enum to every possible backend code would make the plugin a maintenance treadmill chasing OSS releases. Instead PlayLink commits to a stable, shape-classified surface and lets projects extract finer-grained failure semantics from the platform layer they actually ship on.

How projects extract more detail:

  1. Inspect LogPlayLink for the native OSS error string PlayLink prints alongside each Failed_BackendError.
  2. For richer fidelity, subscribe to your OSS's native error delegates directly (e.g. IOnlineSubsystem::OnConnectionStatusChanged, Steam's SteamServersConnectFailure, EOS's EOS_Connect_OnAuthExpirationCallback), they live alongside PlayLink and don't conflict.
  3. Map backend codes to project-specific user-facing strings ("update your game" / "this server isn't available in your region" / "your friend doesn't own this game") in your UI layer.
C++
// Generic PlayLink handler covers 80% of UI cases
void UMySocialSubsystem::HandleSessionJoined(EPlayLinkResult Result, FPlayLinkSessionHandle Handle, FString ConnectString)
{
    switch (Result)
    {
        case EPlayLinkResult::Success:             ClientTravel(ConnectString); break;
        case EPlayLinkResult::Failed_Full:         ShowToast("Session is full"); break;
        case EPlayLinkResult::Failed_NotLoggedIn:  ShowLoginPrompt(); break;
        case EPlayLinkResult::Failed_Network:      ShowToast("Network error, check connection"); break;
        case EPlayLinkResult::Failed_BackendError: ShowToast("Could not join (see log)"); break;
        default:                                  ShowToast("Could not join"); break;
    }
}

// Project can layer backend-specific refinement on top:
case EPlayLinkResult::Failed_BackendError:
    if (Router->GetBackend() == EPlayLinkBackend::Steam)
    {
        // Inspect SteamAPI directly for the last error if needed
    }
    break;

Lobbies

UPlayLinkLobbySubsystem implements lobbies as presence-true sessions on top of IOnlineSession. Use lobbies for the social layer that exists before a match, character select, ready checks, voice chat, party formation. Lobbies are discoverable through the friends list and rich presence; the SEARCH_LOBBIES filter returns presence-true sessions on Steam and dedicated lobbies on EOS.

Lifecycle

FunctionDescription
Action_HostLobby(LocalUserNum, MaxPlayers, bLanOnly) Create a presence-true lobby. Result fires on OnLobbyCreated with the resolved FPlayLinkLobbyHandle.
Action_LeaveLobby(LocalUserNum) Exit the local user's lobby. Fires OnLobbyLeft on completion.
Action_FindLobbies(LocalUserNum, MaxResults, bLanOnly) Search for lobbies. Results land on OnLobbiesFound as a TArray<FPlayLinkLobbySearchResult>.
Action_JoinLobby(LocalUserNum, SearchResult) Join a lobby returned from a search. Fires OnLobbyJoined with the resolved handle.
FindAndJoinAnyLobby(LocalUserNum, bLanOnly) Convenience scenario: search then auto-join the first hit. Useful for quick-play buttons. Composes Action_FindLobbies and Action_JoinLobby.

State events

DelegateParameters
OnLobbyCreatedEPlayLinkResult, FPlayLinkLobbyHandle
OnLobbyJoinedEPlayLinkResult, FPlayLinkLobbyHandle
OnLobbyLeftEPlayLinkResult, FName LobbyName
OnLobbiesFoundEPlayLinkResult, TArray<FPlayLinkLobbySearchResult>
OnLobbyMemberJoinedFPlayLinkUserId MemberId
OnLobbyMemberLeftFPlayLinkUserId MemberId
OnLobbySettingsChangedFPlayLinkLobbySettings NewSettings

Sessions

UPlayLinkSessionSubsystem handles in-match sessions, listen-server hosts, search, and join with ServerTravel. Sessions differ from lobbies: they correspond to the actual gameplay world. Hosting a session opens a listen server and travels the host into the gameplay map; joining a session resolves a connect string and travels the client.

Lifecycle

FunctionDescription
Action_HostListenSession(LocalUserNum, MaxPlayers, bLanOnly) Create a listen-server session. Hosting also seamless-travels the host into the gameplay map. Fires OnSessionCreated.
Action_DestroySession(LocalUserNum) End the local user's session and return to the front-end map. Fires OnSessionDestroyed.
Action_FindSessions(LocalUserNum, MaxResults, bLanOnly) Search for joinable sessions. Results land on OnSessionsFound as TArray<FPlayLinkSessionSearchResult>.
Action_JoinSession(LocalUserNum, SearchResult) Resolve the connect string and travel the client. Fires OnSessionJoined with the connect string for inspection.
FindAndJoinAnySession(LocalUserNum, bLanOnly) Convenience scenario for quick-play. Searches, then joins the first hit on completion.

State events

DelegateParameters
OnSessionCreatedEPlayLinkResult, FPlayLinkSessionHandle
OnSessionJoinedEPlayLinkResult, FPlayLinkSessionHandle, FString ConnectString
OnSessionDestroyedEPlayLinkResult, FName SessionName
OnSessionsFoundEPlayLinkResult, TArray<FPlayLinkSessionSearchResult>

Parties

UPlayLinkPartySubsystem manages persistent groups that travel together across matches. Parties have a leader, members, and may persist through multiple lobbies / sessions. The subsystem is intentionally backend-agnostic: the actual implementation lives in a swappable UPlayLinkPartyServiceBase subclass picked at startup. The default UPlayLinkLobbyPartyService models parties as presence-true lobbies, which works on every backend without requiring dedicated party support.

Lifecycle

FunctionDescription
Action_CreateParty(LocalUserNum, MaxMembers) Create a party with the local user as leader. Fires OnPartyCreated with the resolved FPlayLinkPartyHandle.
Action_LeaveParty(LocalUserNum) Exit the local user's party. Fires OnPartyLeft.
InviteToParty(LocalUserNum, FriendId) Invite a friend to the active party. Outbound invite is dispatched through UPlayLinkFriendsSubsystem.
GetPartyMembers(LocalUserNum) Returns the current member list as TArray<FPlayLinkPartyMember>.

State events

DelegateParameters
OnPartyCreatedEPlayLinkResult, FPlayLinkPartyHandle
OnPartyJoinedEPlayLinkResult, FPlayLinkPartyHandle
OnPartyLeftEPlayLinkResult, FName PartyName
OnPartyMemberJoinedFPlayLinkUserId MemberId
OnPartyMemberLeftFPlayLinkUserId MemberId
OnPartyLeaderChangedFPlayLinkUserId NewLeaderId
To swap the party backend, subclass UPlayLinkPartyServiceBase and set the new class in your project config. The subsystem instantiates whichever class is configured at Initialize().

Matchmaking

UPlayLinkMatchmakingSubsystem drives the canonical search-then-join-or-host loop that most multiplayer games on UE5 build by hand. Submit a ticket, the service searches for matching sessions, joins the best result, and falls back to hosting a new session if nothing suitable is found within the configured timeout. Backed by a pluggable UPlayLinkMatchmakingServiceBase so projects can swap in custom strategies (skill-bucketed, region-aware, party-following, dedicated-server-list) without touching gameplay code.

Ticket lifecycle

Submit one ticket per matchmaking attempt. The subsystem broadcasts status transitions through OnStatusChanged as the service moves through the state machine, and a single final outcome through OnCompleted.

flow
Idle  ──►  Searching  ──►  Found  ──►  Joining  ──►  Completed
                       │
                       ├──►  Hosting  ──►  Completed
                       │
                       ├──►  Failed
                       │
                       └──►  Cancelled

FPlayLinkMatchmakingTicket

PropertyTypeDescription
GameModeTags TArray<FString> Free-form mode tags. Default service maps the first entry to the session's GameMode attribute for filtering. Custom services may implement alternative semantics.
NumericAttributes TMap<FString, int32> Skill / region / level filters. Service-specific semantics, the default service ignores these; project subclasses use them for ELO bucketing.
StringAttributes TMap<FString, FString> Region / language / mode filters. Service-specific semantics.
PartySize int32 Slots the ticket reserves on the chosen session. Default service rejects sessions with fewer than PartySize open slots.
MaxWaitSeconds float Search budget. Default service falls back to host (or fails) when the budget elapses with no successful join.
bFallbackToHost bool When true, the service hosts a new session after MaxWaitSeconds rather than failing. Project authors usually leave this on for quick-play and off for ranked.
bLanOnly bool Restricts the search and any host fallback to LAN. Useful for dev testing.

Default service, UPlayLinkSessionSearchMatchmaker

Shipped as the fallback when no [PlayLink] MatchmakingServiceClass=... is set in DefaultGame.ini. Implements:

  1. FindSessions filtered by the first GameModeTag and the bLanOnly flag
  2. Picks the best candidate, lowest ping with at least PartySize open slots
  3. JoinSession on the chosen result
  4. If no candidate appears within MaxWaitSeconds:
    • bFallbackToHost=true: creates a new advertised session with the ticket's first GameModeTag as its GameMode attribute
    • bFallbackToHost=false: surfaces Failed_Timeout

Minimal C++ usage

C++
UPlayLinkMatchmakingSubsystem* MM = GI->GetSubsystem<UPlayLinkMatchmakingSubsystem>();

MM->OnStatusChanged.AddDynamic(this, &ThisClass::HandleStatus);
MM->OnCompleted.AddDynamic(this, &ThisClass::HandleCompleted);

FPlayLinkMatchmakingTicket Ticket;
Ticket.GameModeTags = { TEXT("Ranked"), TEXT("Solo") };
Ticket.PartySize = 1;
Ticket.MaxWaitSeconds = 60.0f;
Ticket.bFallbackToHost = true;

MM->StartMatchmaking(0, Ticket);

Swapping the strategy

Subclass UPlayLinkMatchmakingServiceBase and override the pure-virtual StartMatchmaking / CancelMatchmaking / GetStatus / GetEstimatedWaitSeconds. Point the config entry at your subclass:

ini
[PlayLink]
MatchmakingServiceClass=/Script/MyGame.MyEloMatchmaker

The subsystem instantiates the configured class at Initialize and forwards every BP-callable function to it. Project code keeps calling the same UPlayLinkMatchmakingSubsystem API, only the strategy underneath changes.

The dev panel exposes Scenario_QuickMatch(bLanOnly) and PlayLinkMatchmake exec command for one-click testing, sensible defaults (60s search, fallback-to-host, solo party) so you can validate the loop without building any project UI.

Travel

Hosting a session advertises it through the OSS, but the host's UE network listener only spins up after a ServerTravel with ?listen appended to the URL. PlayLink does not own the gameplay map, your project picks the destination and drives travel. The PlayLinkDev module ships C++ wrappers on UPlayLinkDevWidgetBase that codify the canonical pattern, so dev-panel travel works out of the box and your project subclass can call the same functions.

End-to-end flow

1

Host advertises the session

Action_HostListenSession, the OSS session is created with bShouldAdvertise=true and registered with the matchmaking layer.

2

Host opens the listen server

Action_HostTravelToListenServer, UWorld::ServerTravel(MapPath + "?listen", true). By default MapPath is the host's current map (no configuration required); set DevTravelMapOverride in Project Settings to route across maps. The ?listen suffix tells UE to spin up the listen-server NetDriver; the OSS attaches its session info to the listening port.

3

Client finds the session

Action_FindSessions, locates the host's advertised session via the OSS search interface.

4

Client joins and receives the connect string

Action_JoinSession(searchResult), the OSS resolves the connect string. OnSessionJoined fires with (Result, Handle, ConnectString).

5

Client travels to the host

Action_TravelToJoinedSession, calls APlayerController::ClientTravel(ConnectString, TRAVEL_Absolute, false). The client lands in the host's map.

UPlayLinkDevWidgetBase travel API

The dev widget caches the connect string from the most recent successful OnSessionJoined and exposes BlueprintCallable wrappers. Subclass the widget in your project (or call the C++ directly), no need for BP graphs to reach Get World or Get Game Mode.

FunctionDescription
Action_HostTravelToListenServer() Validates host state, then World->ServerTravel(MapPath + "?listen", true). By default reloads the host's current map as a listen server, no project configuration required. Returns true if travel was initiated.
Action_TravelToJoinedSession() Consumes the cached connect string and calls PC->ClientTravel(URL, TRAVEL_Absolute, false). Returns true if a connect string was available.
HasPendingClientTravel() BlueprintPure helper for binding the Travel button's IsEnabled, true only after a successful OnSessionJoined.
UPlayLinkDevSettings::DevTravelMapOverride Optional override exposed in Project Settings → Plugins → PlayLink Dev → Travel. Set when the project needs to route from one map (e.g. a lobby) to a different map (e.g. a gameplay arena) on host-travel. Lives on settings rather than the widget Class Defaults so projects don't have to edit the plugin's shipped UMG asset.

Connect string lifecycle

OnSessionJoined hands the resolved connect string back through the third delegate parameter. The dev widget caches it on success only, failed joins clear the cache so a stale URL can't fire later. OnSessionDestroyed also clears the cache: a destroyed session's URL belongs to a session that no longer exists.

C++
// Bind once during init
SessionSubsystem->OnSessionJoined.AddDynamic(this, &UMyClass::HandleJoined);

// Capture and travel
void UMyClass::HandleJoined(EPlayLinkResult Result, FPlayLinkSessionHandle Handle, FString ConnectString)
{
    if (Result == EPlayLinkResult::Success && !ConnectString.IsEmpty())
    {
        GetOwningPlayer()->ClientTravel(ConnectString, ETravelType::TRAVEL_Absolute, false);
    }
}
Order matters. OnSessionJoined can fire before the host has completed its ServerTravel, in which case the OSS join succeeded but the UE listen server isn't up yet and ClientTravel will fail. The host should travel first, then clients join. The dev widget validates this state for you.
Pure-Blueprint projects can also use UGameplayStatics::OpenLevel("MyMap?listen") as an alternative to ServerTravel, but the C++ wrappers above are the recommended path because they share state with the dev panel and validate host/client status before firing.

Achievements

UPlayLinkAchievementsSubsystem wraps IOnlineAchievements with the BP-callable surface most projects actually use. Achievement IDs are defined at the OSS level (Steam Achievements admin, EOS Achievement Service definitions), the subsystem doesn't validate IDs, it just routes the OSS calls and surfaces results as EPlayLinkResult.

Public surface

FunctionDescription
UnlockAchievement(LocalUserNum, AchievementId) Writes a full unlock (100%) for the achievement. Result delivered via OnAchievementUnlocked.
GetAchievementProgress(LocalUserNum, AchievementId) BP-pure. Returns 0.0–1.0 for the local player's cached progress. Returns 0 if the achievement isn't loaded, call RefreshAchievements first if needed.
IsAchievementUnlocked(LocalUserNum, AchievementId) BP-pure convenience: returns true when progress >= 1.0.
RefreshAchievements(LocalUserNum) Forces a fresh OSS read of the player's achievement state. Result delivered via OnAchievementsLoaded.

Events

DelegateParameters
OnAchievementUnlockedEPlayLinkResult, FString AchievementId
OnAchievementsLoadedEPlayLinkResult
Incremental achievements (e.g. "kill 100 enemies") are project-side bookkeeping. Track the counter in your save game or PlayerState; call UnlockAchievement once it crosses the threshold. PlayLink always writes 100%, the OSS handles the actual unlock transition.

Stats

UPlayLinkStatsSubsystem wraps IOnlineStats with the buffered-write pattern most gameplay code expects. Writes are cheap (local cache) and persisted in batches via FlushStats. Stat IDs are project-defined and registered at the OSS level (Steamworks Stats admin, EOS Stat Definitions).

Buffered-write pattern

Writes go to the local cache instantly; the backend round-trip happens on Flush. This matches how most projects want to track stats, increment per-event, persist on match end or interval.

C++
UPlayLinkStatsSubsystem* Stats = GI->GetSubsystem<UPlayLinkStatsSubsystem>();

// During gameplay, cheap, local-only
Stats->IncrementStatInt(0, TEXT("stat_kills"), 1);
Stats->SetStatFloat(0, TEXT("stat_best_combo_time"), 12.4f);

// On match end, persist to backend
Stats->OnStatsFlushed.AddDynamic(this, &ThisClass::HandleStatsFlushed);
Stats->FlushStats(0);

Public surface

FunctionDescription
SetStatInt / SetStatFloatWrite to local cache (no backend hit).
IncrementStatInt(StatName, Delta)Read-modify-write on local cache. Creates the stat at 0 if not yet cached.
GetStatInt / GetStatFloatBP-pure cache read. Returns 0 if not cached, call QueryStats first if needed.
FlushStats(LocalUserNum)Pushes buffered writes via IOnlineStats::UpdateStats. Result on OnStatsFlushed.
QueryStats(LocalUserNum, StatNames)Refreshes local cache from OSS. Result on OnStatsQueried.

Pairing with Achievements

Stats hold the counter; Achievements hold the unlock state, two different concerns that pair naturally:

C++
// Track the counter
Stats->IncrementStatInt(0, TEXT("stat_total_kills"), 1);

// Check threshold against the local cache
if (Stats->GetStatInt(0, TEXT("stat_total_kills")) >= 100)
{
    Achievements->UnlockAchievement(0, TEXT("ACH_KILL_100"));
}

Leaderboards

UPlayLinkLeaderboardsSubsystem wraps IOnlineLeaderboards with the four fetch patterns most games ship. Leaderboard IDs are project-defined and registered at the OSS level (Steamworks Leaderboards admin, EOS Leaderboards derived from Stat Definitions).

Public surface

FunctionDescription
SubmitScore(LocalUserNum, BoardName, Score)Pushes the local player's score. Sync on most backends, result fires immediately.
FetchRange(LocalUserNum, BoardName, StartRank, Count)Top N entries starting at StartRank. Pass StartRank=0 for the top of the board.
FetchAroundMe(LocalUserNum, BoardName, Range)Up to Range entries above and below the local user's rank.
FetchFriendsBoard(LocalUserNum, BoardName)Friend-filtered ranking.

FPlayLinkLeaderboardEntry

Each fetch returns an array of these, sorted by Rank ascending.

FieldType
UserIdFPlayLinkUserId
DisplayNameFString
Rankint32 (1 = top)
Scoreint32 (OSS-native scores >2B truncated)

Minimal usage

C++
UPlayLinkLeaderboardsSubsystem* LB = GI->GetSubsystem<UPlayLinkLeaderboardsSubsystem>();
LB->OnLeaderboardFetched.AddDynamic(this, &ThisClass::HandleLeaderboardFetched);

// On match end, push the score
LB->SubmitScore(0, TEXT("lb_weekly_kills"), MyMatchKills);

// On opening the leaderboard UI
LB->FetchRange(0, TEXT("lb_weekly_kills"), 0, 100);
Default write policy is KeepBest + descending sort (highest score wins). Projects needing different sort/update semantics should register the board with custom metadata in the OSS dashboard, the subsystem submits the score, the backend resolves the ranking rules.

Blueprint Async Actions

Every async PlayLink operation ships as a UBlueprintAsyncActionBase node with On Success / On Failure execution pins. Designers get a single graph node that drives the operation and returns through the matching pin when it resolves, no manual delegate binding. The subsystem APIs remain available for C++ users and project code that prefers delegate-driven flows.

Available nodes

NodeWrapsSuccess pin payload
PlayLink Login UPlayLinkIdentitySubsystem::Login Result, UserId, LocalUserNum
PlayLink Create Lobby UPlayLinkLobbySubsystem::CreateLobby Result, LobbyHandle
PlayLink Find Lobbies UPlayLinkLobbySubsystem::FindLobbies Result, Results array
PlayLink Join Lobby UPlayLinkLobbySubsystem::JoinLobby Result, LobbyHandle
PlayLink Create Session UPlayLinkSessionSubsystem::CreateSession Result, SessionHandle
PlayLink Find Sessions UPlayLinkSessionSubsystem::FindSessions Result, Results array
PlayLink Join Session UPlayLinkSessionSubsystem::JoinSession Result, SessionHandle, ConnectString
PlayLink Start Matchmaking UPlayLinkMatchmakingSubsystem::StartMatchmaking Result, SessionHandle

When to use async actions vs. delegate binding

Both APIs are first-class. Pick based on the flow you're building:

ScenarioUse
A button click that drives one operation and continues on success/failure Async action node
A persistent UI that needs to react to backend-initiated events (friend invites, host migrations, presence updates) Subsystem delegate binding
Matchmaking flow with progress UI showing search/found/joining state Both, async node for the final outcome, subsystem's OnStatusChanged for intermediate states
Async actions are one-shot: each node creates a fresh instance, fires once on its matching pin, and self-destroys. Don't try to hold a reference across multiple operations, create a new node per call.

Dev Panel

The PlayLinkDev module is a developer-only sibling to PlayLink. It's marked Type: "Developer" in the .uplugin so it ships with editor and Development/DebugGame builds and is stripped from Shipping. PlayLinkDev provides three things: an auto-registered UCheatManagerExtension, an in-memory event log subsystem, and a UMG debug panel.

Console exec commands

Drop the in-game console (~) and run any of these. They auto-register on every UCheatManager creation, so they're available on the standard player controller.

CommandDescription
PlayLinkOpenOpen the dev panel.
PlayLinkCloseClose the dev panel.
PlayLinkToggleToggle the dev panel.
PlayLinkLoginDrive UPlayLinkIdentitySubsystem::Login.
PlayLinkHostListen [MaxPlayers] [bLanOnly]Host a listen-server session.
PlayLinkFindAndJoin [bLanOnly]Search for sessions and auto-join the first hit.
PlayLinkHostLobby [MaxPlayers] [bLanOnly]Host a presence-true lobby.
PlayLinkFindAndJoinLobby [bLanOnly]Search and auto-join the first lobby.
PlayLinkCreateParty [MaxMembers]Create a party with the local user as leader.
PlayLinkRefreshFriendsRe-read the friends list.
PlayLinkDumpLogDump the captured event log to the output log.

Debug panel

The dev panel ships as a UMG widget at /PlayLink/WBP_PlayLinkDevWidget. PlayLinkOpen spawns it on the local player controller and switches input mode to UI-only with the cursor visible, same pattern as a pause menu. Esc closes the panel via NativeOnKeyDown; no per-project BP wiring needed for the dismiss shortcut.

The panel renders one card per subsystem (Backend, Identity, Friends, Lobby, Session, Party, Event Log). The C++ base class UPlayLinkDevWidgetBase exposes BP-pure getters for every subsystem's state and BlueprintImplementableEvent refresh hooks designers override to repopulate cards. Project authors can subclass UPlayLinkDevWidgetBase in their own project to customize the layout and point Project Settings → Plugins → PlayLink Dev → Dev Widget Class at the subclass.

Event log subsystem

UPlayLinkDevSubsystem subscribes to every PlayLink delegate and rolls a 256-entry circular buffer of FPlayLinkDevLogEntry structs (timestamp, source enum, severity, message). The dev panel binds to OnLogEntryAdded and renders log lines as they land. PlayLinkDumpLog prints the captured buffer to the engine log; PlayLinkClearLog resets it.

PlayLinkDev is a Type: "Developer" module. It is stripped from Shipping builds automatically, no manual exclusion needed when you cook for release.