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.
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
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
| Scenario | Use |
|---|---|
| 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
| Concept | Steam | EOS | Null / 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 |
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.
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++.
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.
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.
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.
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
// In your subsystem header
UFUNCTION()
void HandleFriendsListUpdated(int32 LocalUserNum, EPlayLinkResult Result);
UFUNCTION()
void HandleLobbyCreated(EPlayLinkResult Result, FPlayLinkLobbyHandle Handle); // 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.
| Backend | Resolved When | Notes |
|---|---|---|
| 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
UPlayLinkSubsystem* Router = GI->GetSubsystem<UPlayLinkSubsystem>();
if (Router->IsReady())
{
EPlayLinkBackend Backend = Router->GetBackend();
UE_LOG(LogTemp, Log, TEXT("PlayLink active: %s"),
*UEnum::GetValueAsString(Backend));
} 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.).
| Function | Description |
|---|---|
| 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
| Delegate | Parameters | When 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.
| State | When applied | Status 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.
; 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.
| Property | Type | Description |
|---|---|---|
| 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
| Delegate | Parameters | When 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. |
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.
| Function | Description |
|---|---|
| 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.
// 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.
| Code | What PlayLink guarantees |
|---|---|
| Success | Operation landed; consumer can read result data. |
| Failed_NoBackend | No OSS resolved (or the OSS interface is unavailable). |
| Failed_NotLoggedIn | Local user is not authenticated. |
| Failed_Network | Generic network failure reported by the OSS. |
| Failed_Timeout | Operation did not complete within the OSS-defined window. |
| Failed_AlreadyInState | Already in a session/lobby/party (or already logged in). |
| Failed_NotInState | Not currently in the state the op requires (e.g. leaving a session you're not in). |
| Failed_PermissionDenied | Backend rejected for permissions / privacy / invite rules. |
| Failed_Full | Session or lobby is full. |
| Failed_Cancelled | User-initiated cancel. |
| Failed_BackendError | Backend 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 failure | Where 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:
- Inspect
LogPlayLinkfor the native OSS error string PlayLink prints alongside eachFailed_BackendError. - For richer fidelity, subscribe to your OSS's native error delegates directly (e.g.
IOnlineSubsystem::OnConnectionStatusChanged, Steam'sSteamServersConnectFailure, EOS'sEOS_Connect_OnAuthExpirationCallback), they live alongside PlayLink and don't conflict. - 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.
// 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
| Function | Description |
|---|---|
| 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
| Delegate | Parameters |
|---|---|
| OnLobbyCreated | EPlayLinkResult, FPlayLinkLobbyHandle |
| OnLobbyJoined | EPlayLinkResult, FPlayLinkLobbyHandle |
| OnLobbyLeft | EPlayLinkResult, FName LobbyName |
| OnLobbiesFound | EPlayLinkResult, TArray<FPlayLinkLobbySearchResult> |
| OnLobbyMemberJoined | FPlayLinkUserId MemberId |
| OnLobbyMemberLeft | FPlayLinkUserId MemberId |
| OnLobbySettingsChanged | FPlayLinkLobbySettings 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
| Function | Description |
|---|---|
| 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
| Delegate | Parameters |
|---|---|
| OnSessionCreated | EPlayLinkResult, FPlayLinkSessionHandle |
| OnSessionJoined | EPlayLinkResult, FPlayLinkSessionHandle, FString ConnectString |
| OnSessionDestroyed | EPlayLinkResult, FName SessionName |
| OnSessionsFound | EPlayLinkResult, 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
| Function | Description |
|---|---|
| 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
| Delegate | Parameters |
|---|---|
| OnPartyCreated | EPlayLinkResult, FPlayLinkPartyHandle |
| OnPartyJoined | EPlayLinkResult, FPlayLinkPartyHandle |
| OnPartyLeft | EPlayLinkResult, FName PartyName |
| OnPartyMemberJoined | FPlayLinkUserId MemberId |
| OnPartyMemberLeft | FPlayLinkUserId MemberId |
| OnPartyLeaderChanged | FPlayLinkUserId NewLeaderId |
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.
Idle ──► Searching ──► Found ──► Joining ──► Completed
│
├──► Hosting ──► Completed
│
├──► Failed
│
└──► Cancelled FPlayLinkMatchmakingTicket
| Property | Type | Description |
|---|---|---|
| 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:
FindSessionsfiltered by the firstGameModeTagand thebLanOnlyflag- Picks the best candidate, lowest ping with at least
PartySizeopen slots JoinSessionon the chosen result- If no candidate appears within
MaxWaitSeconds:bFallbackToHost=true: creates a new advertised session with the ticket's firstGameModeTagas itsGameModeattributebFallbackToHost=false: surfacesFailed_Timeout
Minimal C++ usage
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:
[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.
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
Host advertises the session
Action_HostListenSession, the OSS session is created with bShouldAdvertise=true and registered with the matchmaking layer.
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.
Client finds the session
Action_FindSessions, locates the host's advertised session via the OSS search interface.
Client joins and receives the connect string
Action_JoinSession(searchResult), the OSS resolves the connect string. OnSessionJoined fires with (Result, Handle, ConnectString).
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.
| Function | Description |
|---|---|
| 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.
// 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);
}
} 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. 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
| Function | Description |
|---|---|
| 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
| Delegate | Parameters |
|---|---|
| OnAchievementUnlocked | EPlayLinkResult, FString AchievementId |
| OnAchievementsLoaded | EPlayLinkResult |
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.
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
| Function | Description |
|---|---|
| SetStatInt / SetStatFloat | Write 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 / GetStatFloat | BP-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:
// 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
| Function | Description |
|---|---|
| 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.
| Field | Type |
|---|---|
| UserId | FPlayLinkUserId |
| DisplayName | FString |
| Rank | int32 (1 = top) |
| Score | int32 (OSS-native scores >2B truncated) |
Minimal usage
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); 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
| Node | Wraps | Success 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:
| Scenario | Use |
|---|---|
| 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 |
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.
| Command | Description |
|---|---|
| PlayLinkOpen | Open the dev panel. |
| PlayLinkClose | Close the dev panel. |
| PlayLinkToggle | Toggle the dev panel. |
| PlayLinkLogin | Drive 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. |
| PlayLinkRefreshFriends | Re-read the friends list. |
| PlayLinkDumpLog | Dump 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.