📺 Video demo on YouTube · 💻 Source code on GitHub
This project is a 2-player co-op prototype where both players must cooperate to solve puzzles and reach the win area together. Neither player can complete the level alone — pressure plates require simultaneous activation, keys need to be collected and delivered, and the win condition only triggers when both characters are inside the goal zone at the same time.
The interesting engineering challenge wasn’t the gameplay itself but the infrastructure underneath it: how do you wire Steam session creation and discovery to a Blueprint main menu without coupling the UI to the Online Subsystem API, and how do you design server-authoritative puzzle actors that replicate correctly to clients without scattering RPC logic across every class?
Based on the Unreal Engine 5 Multiplayer course on Udemy.
The Session Layer
All Steam session logic lives in UMultiplayerSessionsSubsystem, a UGameInstanceSubsystem. The main menu only knows about two methods — CreateServer and FindServer — and two delegates it binds to for the result. It never touches IOnlineSubsystem directly.
The subsystem’s lifetime matches the UGameInstance, which means it survives the ServerTravel from the main menu map to the game map. This is a non-trivial requirement: the host creates a session before travelling, and that session must remain valid and queryable by clients throughout the match. A subsystem is the right fit precisely because its lifetime is not tied to any specific level.
One detail that required deliberate design is server discovery by name. Steam sessions are identified internally by session ID, not by a human-readable name — so two hosts named “MyGame” would be indistinguishable from the client’s perspective if you relied on Steam’s built-in naming. The solution is to embed a custom SERVER_NAME key in FOnlineSessionSettings when creating the session, and iterate FOnlineSessionSearch results to find the entry whose SERVER_NAME matches what the client typed. The key is defined as a namespace-scoped FName constant in the .cpp file so it cannot silently diverge between host and client builds.
Another edge case: if the player creates a server, returns to the main menu, and tries to create again, a session already exists and Steam rejects the new creation. CreateServer detects this via GetNamedSession, destroys the existing session first, and re-creates in OnDestroySessionComplete via a CreateServerAfterDestroy flag. The async nature of session operations makes this pattern necessary — you cannot destroy and create in the same frame.
The subsystem also handles the NULL subsystem fallback transparently via IsNullSubsystem(). When Steam is not running (editor sessions, CI builds), the subsystem switches session settings and search to LAN mode automatically, so the same code path works for local testing without any manual toggling.
The Gameplay Layer
The puzzle mechanics are built from five replicated actor classes and one component that connects them.
UTransporter is an UActorComponent that moves its owner between two world-space points based on how many of its registered trigger actors are simultaneously activated. It maintains an ActivatedTriggerCount integer. When that count equals TriggerActors.Num(), it interpolates the owner toward EndPoint using FMath::VInterpConstantTo; otherwise it returns to StartPoint. Speed is derived as Distance / MoveTime so the travel duration is always predictable regardless of the distance set in the editor.
Using a counter rather than a boolean is a deliberate choice. A boolean “all triggered” flag would lose information when multiple triggers are involved — if one plate activates while another deactivates in the same tick, a boolean collapses both events into a single undefined state. The integer correctly tracks “2 of 3 plates pressed” as a distinct state from “1 of 3.”
Movement runs only when HasAuthority() is true. The owning actor’s SetReplicateMovement(true) propagates the result to clients without any additional RPCs on UTransporter itself.
APressurePlate queries overlapping actors tagged "TriggerActor" every tick (server only) and broadcasts OnActivation or OnDeactivation delegates when the state changes. It owns a UTransporter with bOwnerIsTriggerActor = true, which means the plate self-registers as its own trigger — so the plate physically presses 10 cm downward when a player steps on it, driven by the same component that drives doors and platforms.
AMovableActor is the reusable moving platform. It has two UArrowComponent waypoints whose positions are converted to world-space in BeginPlay and handed to UTransporter. The designer places the actor in the level, adjusts Point2’s arrow to define the destination, and assigns trigger actors to the Transporter’s array in the Details panel — no code changes needed to configure a new puzzle element.
ACollectableKey spins in place and detects player overlap on the server. When collected, it sets bIsCollected = true and broadcasts an OnCollection delegate that UTransporter subscribes to, which can unlock a door or platform. The visual result — hiding the mesh, playing the audio — is handled in OnRep_bIsCollected, which runs on clients automatically via DOREPLIFETIME and must be called manually on the server (RepNotify functions are not invoked automatically on the machine that sets the value; the server must call them explicitly after changing the replicated variable).
AWinArea checks every tick whether both player characters are inside its UBoxComponent. When the condition is met, it calls MulticastRPCWin — a Reliable NetMulticast RPC — which broadcasts OnWinCondition on every connected client. The Reliable qualifier is intentional: the win event fires once per match and a dropped packet would leave one player’s UI in a broken state. For a one-shot event the retransmit cost is negligible.
Replication Design
The rule applied consistently across all gameplay actors is: authority determines, replication distributes.
Every state change — plate activation, key collection, platform movement, win detection — is detected and decided on the server. The server then uses one of three mechanisms to propagate results to clients:
SetReplicateMovement(true)onAMovableActorandAPressurePlatehandles position automatically viaUCharacterMovementComponent-style movement replication.DOREPLIFETIMEwithReplicatedUsingonACollectableKey::bIsCollectedhandles the key’s collected state and triggers the visual update viaOnRep_bIsCollectedon each client.NetMulticast ReliableonAWinArea::MulticastRPCWinhandles the win event, where a guaranteed broadcast to all clients is required.
Each mechanism is chosen based on what it needs to convey: continuous position changes use movement replication; discrete state transitions use DOREPLIFETIME; one-shot events that must reach everyone use a Reliable Multicast RPC.
What I’d Extend Next
The session subsystem is already structured to swap Steam for any other Online Subsystem backend — EOS, Xbox Live, or a custom backend — by changing only the DefaultEngine.ini configuration. The C++ code is backend-agnostic by design.
The puzzle system could be extended cleanly by adding new trigger actor types that implement the same OnActivation / OnDeactivation delegate pattern — a timed switch, a proximity sensor, or a physics object landing on a plate would all plug into UTransporter without touching its code.
The win condition currently hardcodes 2 players. Making RequiredPlayerCount an EditAnywhere property would generalize it for lobbies of any size, which is a natural next step if the session max connections are increased beyond 2.
Leave a comment