Skip to Content
GuideNavigation

Navigation

Fram3 has two distinct navigation systems that address different needs in a game UI:

  • In-screen navigation (Navigator) — a stack-based router that swaps UI screens within the same Unity scene. Use it for menus, sub-menus, overlays, and any flow that lives inside a single scene.
  • Scene navigation (SceneNavigator) — a global static API that loads a new Unity scene, replacing the current one. Use it to move between major game states: main menu to gameplay, gameplay to results screen, and so on.

In-screen navigation with Navigator

Navigator is a StatefulElement that manages a stack of named routes. Only the topmost route is rendered at any given time. Descendants push and pop routes using a NavigatorHandle obtained from the element tree.

Defining routes

Routes are a IReadOnlyDictionary<string, Func<BuildContext, Element>>. Each key is a route name; each value is a builder that receives the current BuildContext and returns the element tree for that screen.

using System.Collections.Generic; using Fram3.UI.Core; using Fram3.UI.Navigation; var routes = new Dictionary<string, Func<BuildContext, Element>> { ["main-menu"] = (context) => new MainMenuScreen(), ["settings"] = (context) => new SettingsScreen(), ["character"] = (context) => new CharacterSelectScreen(), };

Route names are arbitrary strings. Short, descriptive names work better here than path-like conventions — this is a game menu, not a web app.

Mounting the navigator

Pass the routes and an initialRoute to the Navigator constructor. Place it at the root of your AppRoot:

using Fram3.UI.Navigation; using Fram3.UI.Rendering; public sealed class MainMenuApp : AppRoot { protected override Element CreateRoot() => new Navigator( routes: new Dictionary<string, Func<BuildContext, Element>> { ["main-menu"] = (_) => new MainMenuScreen(), ["settings"] = (_) => new SettingsScreen(), ["character"] = (_) => new CharacterSelectScreen(), }, initialRoute: "main-menu" ); }

initialRoute must match a key in routes. Passing an unregistered name throws an ArgumentException at construction time.

Accessing the navigator handle

From any descendant, retrieve the NavigatorHandle via the inherited element lookup:

var nav = context.GetInherited<NavigatorScope>().Navigator;
Method / PropertyDescription
nav.Push(routeName)Pushes a named route onto the stack
nav.Push(routeName, arguments)Pushes a route and attaches an arguments object
nav.Pop()Removes the top route. No-op when CanPop is false
nav.CanPoptrue when there is more than one route on the stack

Example: main menu with sub-menus

A typical game main menu has a root screen with buttons that open nested screens. Each button calls nav.Push; each back button calls nav.Pop.

public sealed class MainMenuScreen : StatelessElement { public override Element Build(BuildContext context) { var nav = context.GetInherited<NavigatorScope>().Navigator; return new Column(mainAxisAlignment: MainAxisAlignment.Center) { Children = new Element[] { new Text("APEX CHRONICLES"), new Button( label: "Play", onPressed: () => nav.Push("character") ), new Button( label: "Settings", onPressed: () => nav.Push("settings") ), new Button( label: "Quit", onPressed: () => Application.Quit() ), } }; } }
public sealed class SettingsScreen : StatelessElement { public override Element Build(BuildContext context) { var nav = context.GetInherited<NavigatorScope>().Navigator; return new Column { Children = new Element[] { new Text("Settings"), new AudioSettings(), new VideoSettings(), new Button( label: "Back", onPressed: () => nav.Pop() ), } }; } }

Passing data between routes

There is no built-in typed argument system. The cleanest pattern is to hold the data in a state field or a Cubit scoped above the navigator, then read it in the route builder via a closure or context.GetInherited.

public sealed class CharacterSelectApp : StatefulElement { public override State CreateState() => new CharacterSelectState(); private sealed class CharacterSelectState : State<CharacterSelectApp> { private CharacterDefinition? _pending; public override Element Build(BuildContext context) { return new Navigator( routes: new Dictionary<string, Func<BuildContext, Element>> { ["roster"] = (ctx) => new RosterScreen(onSelect: character => { SetState(() => _pending = character); ctx.GetInherited<NavigatorScope>().Navigator.Push("confirm"); }), ["confirm"] = (_) => new ConfirmScreen(character: _pending!), }, initialRoute: "roster" ); } } }

Pause menu overlay

A common pattern in games is a pause menu that sits on top of the gameplay HUD in a Stack. The pause menu itself can be its own Navigator so it manages its own sub-screens independently:

new Stack { Children = new Element[] { new GameplayHud(), new PauseMenuNavigator(), } }
public sealed class PauseMenuNavigator : StatefulElement { public override State CreateState() => new PauseMenuState(); private sealed class PauseMenuState : State<PauseMenuNavigator> { private bool _open; public override Element Build(BuildContext context) { if (!_open) { return new Button( label: "Pause", onPressed: () => SetState(() => _open = true) ); } return new Navigator( routes: new Dictionary<string, Func<BuildContext, Element>> { ["pause"] = (_) => new PauseRootScreen(onResume: () => SetState(() => _open = false)), ["settings"] = (_) => new SettingsScreen(), }, initialRoute: "pause" ); } } }

Replacing the entire route map

Because Navigator is a StatefulElement, rebuilding its parent with a new Navigator instance swaps out the entire route map. This is useful when the available screens change based on game state — for example, replacing the pregame lobby navigator with the in-game HUD navigator when a match starts.

Limitations

  • No animated transitions between routes. Routes are swapped synchronously.
  • No global navigator singleton. Each Navigator instance is scoped to its subtree.
  • No built-in typed argument passing. Use state, closures, or a Provider above the navigator.

Scene navigation with SceneNavigator

SceneNavigator is a static, context-free API. It wraps Unity’s SceneManager.LoadSceneAsync and loads the named scene using LoadSceneMode.Single, replacing the current scene entirely.

using Fram3.UI.Navigation; SceneNavigator.GoTo("Gameplay");

The scene name must match a scene registered in File > Build Settings. Passing a null or empty string throws an ArgumentNullException.

SceneOperation

SceneNavigator.GoTo returns a SceneOperation you can use to track the load and react to completion:

MemberDescription
ProgressNormalized load progress in the range [0, 1]. Updated each frame.
IsCompletedtrue once the scene is fully loaded and active.
CompletedEvent raised exactly once when IsCompleted transitions to true.

Showing a loading screen

A loading screen reads Progress each frame to drive a progress bar. Hold the SceneOperation in state, subscribe to a frame callback in InitState to poll progress, and call SetState only from outside Build:

public sealed class LoadingScreen : StatefulElement { private readonly string _sceneName; public LoadingScreen(string sceneName) { _sceneName = sceneName; } public override State CreateState() => new LoadingState(); private sealed class LoadingState : State<LoadingScreen> { private SceneOperation? _operation; private float _progress; public override void InitState() { _operation = SceneNavigator.GoTo(Element!._sceneName); _operation.Completed += () => SetState(() => _progress = 1f); } public override void DidUpdate() { if (_operation != null && !_operation.IsCompleted) { SetState(() => _progress = _operation.Progress); } } public override Element Build(BuildContext context) { return new Column(mainAxisAlignment: MainAxisAlignment.Center) { Children = new Element[] { new Text("Loading..."), new ProgressBar(value: _progress, min: 0f, max: 1f), } }; } } }

Triggering a scene load from a button

SceneNavigator is global and context-free. Call it directly from any button callback or cubit method — no need to thread a navigator handle through the tree:

new Button( label: "Play", onPressed: () => SceneNavigator.GoTo("Gameplay") )
new Button( label: "Main Menu", onPressed: () => SceneNavigator.GoTo("MainMenu") )

Triggering a scene load from a Cubit

For more complex flows — such as waiting for a network response before loading — trigger the scene load from inside a cubit method:

public sealed class MatchmakingCubit : Cubit<MatchmakingState> { public MatchmakingCubit() : base(new MatchmakingState(Searching: true)) { } public async void FindMatch() { var match = await MatchmakingService.FindAsync(); Emit(new MatchmakingState(Searching: false, MatchFound: true)); SceneNavigator.GoTo("Gameplay"); } }

Choosing between the two systems

ScenarioUse
Main menu to settings to character selectNavigator
Opening a pause menu over the HUDNavigator in a Stack
Navigating deeper sub-menus (inventory pages, shop categories)Navigator
Starting a match (main menu scene to gameplay scene)SceneNavigator
Returning to main menu after a match endsSceneNavigator
Showing a loading screen between scenesSceneNavigator + SceneOperation.Progress

A common pattern is to combine both: SceneNavigator handles the coarse transitions between major game states, while Navigator handles all the fine-grained menu flows within each scene.


Summary

  • Use Navigator for menu stacks that live within a single scene. Mount it at the root of your AppRoot, declare all screens as named routes, and call nav.Push / nav.Pop from any descendant.
  • Use SceneNavigator.GoTo(sceneName) to load a Unity scene. The scene name must be in Build Settings. Use the returned SceneOperation to track progress or react to completion.
  • The two systems are independent and complementary. Most games use both.
Last updated on