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 / Property | Description |
|---|---|
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.CanPop | true 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
Navigatorinstance is scoped to its subtree. - No built-in typed argument passing. Use state, closures, or a
Providerabove 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:
| Member | Description |
|---|---|
Progress | Normalized load progress in the range [0, 1]. Updated each frame. |
IsCompleted | true once the scene is fully loaded and active. |
Completed | Event 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
| Scenario | Use |
|---|---|
| Main menu to settings to character select | Navigator |
| Opening a pause menu over the HUD | Navigator 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 ends | SceneNavigator |
| Showing a loading screen between scenes | SceneNavigator + 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
Navigatorfor menu stacks that live within a single scene. Mount it at the root of yourAppRoot, declare all screens as named routes, and callnav.Push/nav.Popfrom any descendant. - Use
SceneNavigator.GoTo(sceneName)to load a Unity scene. The scene name must be in Build Settings. Use the returnedSceneOperationto track progress or react to completion. - The two systems are independent and complementary. Most games use both.