UI & Online · Updated 2552.09.10.16.15

WBPSLMainMenu — spec

The last piece of Multiplayer Phase 1 (Docs/MultiplayerPlan.md §4.2 item 1). The LAN session service shipped in 7b2adca6; this is the screen that drives it. Everything below is UMG

The last piece of Multiplayer Phase 1 (Docs/MultiplayerPlan.md §4.2 item 1). The LAN session service shipped in 7b2adca6; this is the screen that drives it. Everything below is UMG + Blueprint work apart from the single ASLPlayerHUD change described in §1, which is already done.

Companion docs: MultiplayerPlan.md §4 (why), MenusAndOnline.md §5/§7 (layout ownership, the hosting seam), MenuSystemPlan.md §1 (the controller-first contract every screen must satisfy).


#0. What the screen is

Update 2026-08-29 — the button list changed. Settings is replaced by PROFILES, and a new
OPTIONS sibling carries the machine-wide video/audio settings, so the menu is five entries:
Host · Join · Profiles · Options · Quit. Driven by split-screen: per-player settings belong to a profile,
machine-wide ones cannot. §3's Join spec and §2's Host spec are unaffected. → Docs/PlayerProfiles.md §8.

A full-screen front-end page on the MenuStack layer of USLPrimaryGameLayout, living in /Game/SystemLink/UI/Menus/WBP_SL_MainMenu, parented directly to the C++ USLScreenWidget — not to WBP_SL_Page, whose chrome the main menu does not want. USLScreenWidget is the base built for exactly this ("Main Menu, Pause, Settings, Lobby"), and its constructor already supplies InputModeOnActivate = Menu and bIsBackActionDisplayedInActionBar = true.

⚠ It will not compile until the graph overrides Get Desired Focus Target. USLScreenWidget::ValidateCompiledWidgetTree raises a compile error when a screen has neither an AutoFocusWidget nor that override, because a screen with no initial focus is un-navigable on a controller. Return the Host button from the override. Do not use the AutoFocusWidget details-panel picker — it silently reverts (footgun 6.29), which is why the override is the only real path.

⚠ Dropping WBP_SL_Page means no UCommonBoundActionBar in the tree. Add WBP_SL_CommandBar by hand; without it bIsBackActionDisplayedInActionBar has nothing to advertise to, and the screen shows no button prompts at all.

ℹ bPauseGameWhileActive is the trap on this map, and parenting to USLScreenWidget avoids it. The C++ default is false, which is what a front-end screen needs. WBP_SL_Page overrides it to True — inherited from having been duplicated out of WBP_SL_Pause — and a main menu carrying that True pauses the world the moment it activates. On MainMenu that stops the Level Blueprint's Delay 6.0 dead (latent nodes do not advance while paused), so LS_MainIntro never plays and it presents as a broken sequence rather than a paused game. Any future front-end screen built on WBP_SL_Page — the join screen included — must uncheck it. Do not fix this by changing WBP_SL_Page itself: WBP_SL_Settings inherits that True and needs it, because pushing Settings deactivates the pause menu underneath, which unpauses.

Four entries, in this order:

EntryDoes
Host GameCreates a LAN session and travels to Level1 as a listen server.
Join GameOpens the join sub-screen (server list + direct IP).
SettingsPushes the existing WBP_SL_Settings. Already built — this is just a push.
QuitQuit Game, after a confirm modal.

It is the root of the menu stack. Unlike every other screen, B/Esc must not pop it — there is nothing underneath, and popping it leaves the player staring at the diorama with no input. Uncheck bIsBackHandler on this screen; the USLScreenWidget constructor sets it true, so this is an override, not a default.


#1. ⚠ Prerequisite — the menu map has no UI layout at all

Do this before authoring the widget, or the screen has nowhere to be pushed.

USLPrimaryGameLayout::GetForPlayer() walks PlayerController → HUD → ASLPlayerHUD, and USLCommonActivatableWidget::PushToMenuStack() goes through the same path. BP_MainMenuGameMode overrides nothing — no HUD class, no PlayerController class, no pawn — so on the MainMenu map the HUD is the stock AHUD, there is no USLPrimaryGameLayout, and every push call returns null.

This is exactly the gap MenusAndOnline.md §5 predicted ("the main menu has no pawn, so this must move up"). Two ways out; take the interim one for Phase 1:

  • Interim — DONE 2026-08-26. BP_SL_MenuHUD (/Game/SystemLink/UI/HUD/, parented to ASLPlayerHUD)
  • with LayoutClass = WBP_SL_PrimaryGameLayout and HUDWidgetClass empty, set as BP_MainMenuGameMode's HUD Class. Its OnHUDInitialized pushes WBP_SL_MainMenu to the Menu layer.

  • Proper (defer): USLUIManagerSubsystem owning the layout at GameInstance level, per
  • MenusAndOnline.md §5. Correct long-term, but it is a refactor of in-game UI ownership and it is not what Phase 1 is blocked on.

This needed the one C++ change in Phase 1: ASLPlayerHUD::BeginPlay used to treat an unset HUDWidgetClass as a failure — it logged an Error and called OnHUDInitialized(false). The layout was already built by then, so a menu HUD could push off that false, but it would be succeeding through a failure branch and logging an error on every boot. An empty USLHUDWidget subclass is not an escape either: it has five required BindWidgets and a blank one will not compile. HUDWidgetClass is now genuinely optional — unset means "layout, no gameplay overlay", and bSuccess is honest.

⚠ A HUD actor exists without a pawn — AHUD belongs to the PlayerController, not the pawn — so the interim path works on the pawnless menu map. What does not exist there is a ASLPlayerState or an ASC; if the trimmed HUD BP touches either in OnHUDInitialized, it will null-deref on the menu map only.

#1.1 The Level Blueprint already handles the camera — leave it alone

MultiplayerPlan.md §4.4 listed "the player must view through the cine camera, not spawn a Chief in the set" as an open item. It is already solved, in the MainMenu Level Blueprint, on BeginPlay:

Play Sound 2D (powerful_Cue) → cache the PlayerController → Set View Target with Blend to the CineCameraActor, Blend Time 0.0 (an instant cut, so no black frame) → Create Level Sequence Player for LS_MainIntro (Auto Play off, Disable Movement Input and Disable Look at Input checked, Finish Completion State = Force Keep State) → Set Playback Position frame 0 → Delay 6.0 → Play.

So: music, instant cut to the cine camera, hold on the intro's opening frame for six seconds, then the camera move, and the camera stays where the sequence left it. The stock Pawn the GameMode spawns at the map's single PlayerStart is invisible and never seen, because the view target is retargeted before the first frame is drawn. Nothing here needs changing.

Two consequences for the menu:

  • The six-second hold is now a design decision. The HUD pushes WBP_SL_MainMenu from its own
  • BeginPlay, independently of this graph, so by default the menu appears immediately — six seconds before the camera starts moving. To have it arrive after the intro instead, bind OnFinished on the Sequence Player and push from there rather than from OnHUDInitialized.

  • Force Keep State is what makes the backdrop stable. Without it the camera pops back at the end of
  • the sequence and the menu appears to jump. Do not change it.

Disable Movement Input / Disable Look at Input gate gameplay input only; CommonUI focus and navigation are unaffected, so the menu stays operable while the sequence plays.


#2. Host Game


Host Game (A) → SessionService->HostMatch(Map, ServerName, MaxPlayers)

              → wait for OnHostComplete

              → Success: do NOTHING (the service is already travelling)

              → Failure: re-enable buttons, show the error

Get the service with USLSessionService::Get(self) — the static BP-pure node. Never Get Game Instance Subsystem → USLSessionService: the base is abstract, that node resolves by exact class, and it always returns null. (The header says so; it is the single most likely way to wire this wrong.)

Arguments for v1:

ArgValue
Map/Game/SystemLink/Levels/Level1/Level1
ServerNameAn editable text field on the screen; empty is legal and falls back to the machine name.
MaxPlayers8 — matching the eight PlayerStarts placed in 7b2adca6 and ASLGameModeBase::MaxPlayers.

⚠ On Success, do not travel. HostMatch broadcasts OnHostComplete and then calls ServerTravel — deliberately, because ServerTravel tears the world down and a handler running after it would be operating on a dead world. A Success handler that calls Open Level itself will fight the travel already in flight.

⚠ Every one of these calls is async. The node returns immediately, before the session exists. Any logic that assumes otherwise ships a screen that travels before there is anything to travel into.


#3. Join Game

Ships in two steps, both behind one sub-screen (WBP_SL_JoinGame, same page base).

#3.1 v1 — direct IP

An editable text box plus a Connect button → JoinMatchByAddress("192.168.1.20"). Port optional (:7777 accepted). This path touches no session, no beacon and no subsystem, so it works when broadcast is blocked by a firewall or a switch that eats it — it is the fallback that makes a failed LAN test debuggable, and it stays in the UI permanently, not just until discovery works.

#3.2 v1.1 — the server list

FindMatches(10.0) on screen activation and on a Refresh button, then render OnFindComplete's TArray<FSLSessionSearchResult> in a UCommonListView (per the controller-first contract — never a hand-filled VerticalBox). Columns: ServerName, CurrentPlayers/MaxPlayers, PingMs.

  • PingMs = -1 means "not measured", not a ping of minus one. Null over LAN reports this
  • constantly. Render a dash.

  • Join by ResultIndex, never by row index. They usually match; they stop matching the moment a
  • refresh lands. The service range-checks and fails the join rather than trusting a stale row, so a UI that gets this wrong presents as InvalidResult on a host that is plainly in the list.

  • Clear the list at the start of every FindMatches. Held rows across a refresh are stale indices.
  • An empty array is ambiguous by design — a clean search that found nobody and a broken search both
  • arrive empty. Read Result to pick between "No games found" and the failure text.


#4. Busy state and failure

Disable Host / Join / Refresh whenever IsBusy() is true, and re-enable on the completion delegate. The service refuses overlapping requests with AlreadyInProgress and leaves the in-flight one alone, so the cost of getting this wrong is a confusing screen rather than a corrupt state — but it is confusing in a way that reads as a network fault.

Poll IsBusy on a bound function for the enabled state; do not track a parallel bIsBusy bool on the widget, which can desync from the service across a failed early-out.

One enum covers all three operations, so the screen needs one error surface:

ESLSessionResultPlayer-facing text
Success—
NoSubsystem"Online services unavailable." Configuration, not a runtime fault — it means the [OnlineSubsystem] block is missing or Null failed to load.
Failed"Could not host / find / join." The generic bucket.
AlreadyInProgressNothing — this one is a UI bug, not a player-facing condition. Log it.
InvalidResult"That game is no longer available." Refresh the list.
NoConnectString"Could not connect to that game." The join resolved but yielded no travel URL.

⚠ **Nothing tells the screen that a travel failed.** OnJoinComplete(Success) means the client travel started. If the host has gone away, the failure surfaces as an engine-level connection failure, not as one of these results. GameInstance's NetworkError/TravelError handlers are where that would be caught — out of scope for v1, but it is the reason a join can appear to succeed and then sit there.


#5. Controller-first requirements

MenuSystemPlan.md §1.2 is the definition of done, in full, with the mouse unplugged. The parts this screen is most likely to fail:

  • Buttons are USLButtonBase (WBP_SL_Button / WBP_SL_ButtonLarge), never raw UButton.
  • The four entries live in a VerticalBox, not a CanvasPanel — canvas does not route gamepad
  • navigation, so a canvas layout is a screen a controller cannot move through.

  • Override Get Desired Focus Target in the graph to return the Host button. Do not try to set
  • AutoFocusWidget from the details panel — that picker silently reverts and can crash the UMG editor (Footguns.md).

  • A UCommonBoundActionBar (WBP_SL_CommandBar) is present and shows live prompts.
  • The text fields are the hazard: server name and IP entry are the first KBM-shaped controls in the
  • project. On a gamepad they must open the platform virtual keyboard or be reachable and editable — a join screen a controller cannot type an address into fails the contract.

  • InputModeOnActivate = Menu (full-screen front-end), and bPauseGameWhileActive = false — the menu
  • map has nothing to pause, and SetGamePaused is a no-op once networked anyway.


#6. Testing it

  1. PIE, one machine: Net Mode = Play As Listen Server, 2 players. Exercises travel and spawns, not
  2. discovery — Null's beacon does not meaningfully round-trip inside one PIE process.

  1. Two packaged/standalone instances on one PC: the real check for FindMatches. Launch two
  2. standalone windows; the second should see the first in the list.

  1. Two machines on the LAN: the actual target. If discovery fails here but direct IP works, it is the
  2. beacon or the firewall — bCanUseIPSockets=true is already set in DefaultEngine.ini, so suspect the Windows firewall prompt on the host first.

  1. Eight spawns: confirmed placed in 7b2adca6 at Z 90–114. Worth eyeballing once with several
  2. clients, since two players fighting over one start reads exactly like a replication bug.

⚠ GameDefaultMap / EditorStartupMap are still /Game/Dev/TestMap.TestMap. Standalone launches will open TestMap, not the menu — point them at /Game/SystemLink/Levels/MainMenu/MainMenu when testing the front end, and decide before Phase 1 closes whether that becomes the shipped default.

USLSessionService::MenuMap already defaults to /Game/SystemLink/Levels/MainMenu/MainMenu, so LeaveMatch returns to the right place with no further wiring.


#7. Explicitly out of scope for v1

  • Map or game-type selection on the host screen — one map, Level1. Game-type settings are
  • MultiplayerPlan.md §6.

  • A lobby between hosting and playing. Host travels straight in; joiners arrive into a live match.
  • ℹ Still true after the 2026-09-04 split-screen decision. Local players join from the main menu's identity zone, not from a screen between Host and the match — that was the quadrant player-select design, and it was rejected partly because it would have contradicted this. → PlayerProfiles.md §8.15

  • Party/invites and anything EOS (MenusAndOnline.md Phases D–F).
  • The in-match pause menu's Leave Match entry. LeaveMatch() exists and works; hanging it off the pause
  • screen is a separate small task, and it must be a UI overlay that does not call SetGamePaused on a listen server.