Build a pause menu
You’ll turn the starter screen into a HUD with a score and a pause menu with Resume and Reset score buttons, connect the menu to Unity’s game time and gameplay input, then add keyboard and gamepad navigation so you can use it without a mouse.
Before you start
Open the project from Install and run Loom and make
sure Play mode is stopped. We’ll keep the scene you set up there and replace the contents of UIBridge.cs and the UI files. The controls below include keyboard and gamepad
bindings, so you can follow along with either.
Configure input
When we pause the game, the menu still needs to receive keyboard and gamepad input. We’ll use Unity’s Input System to keep those events arriving while game time is stopped.
- Open Edit > Project Settings > Player > Other Settings > Configuration. Set Active Input Handling to Input System Package (New) or Both. Restart Unity if it asks you to.
- Open Edit > Project Settings > Input System Package. Set Update Mode to Process Events In Dynamic Update (the default).
With dynamic updates, input continues even when we set Time.timeScale to
zero. We’ll use that to pause the game in a moment.
Define the screens and actions
First, we need a way to switch between the HUD and the pause menu. We’ll add a CurrentScreen property to UIBridge so the UI knows which one to display.
We’ll also add a score and three actions: Pause, Resume, and ResetScore.
Replace the contents of Assets/Scripts/UIBridge.cs with:
using System;
using Loom;
public enum GameScreen
{
Hud,
Pause
}
[Bridge]
public partial class UIBridge : LoomBridgeBase
{
public GameScreen CurrentScreen { get; set; } = GameScreen.Hud;
public int Score { get; set; }
private Action<bool> _setPaused;
// Called by the scene component, not exposed to the UI.
public void SetPauseHandler(Action<bool> handler) => _setPaused = handler;
[BridgeAction]
public void Pause() => ChangePause(true);
[BridgeAction]
public void Resume() => ChangePause(false);
[BridgeAction]
public void ResetScore() => Score = 0;
private void ChangePause(bool paused)
{
if (_setPaused == null)
throw new InvalidOperationException("PauseMenuController is not active.");
_setPaused(paused);
CurrentScreen = paused ? GameScreen.Pause : GameScreen.Hud;
}
}
Pausing needs to change game time and input as well as the visible screen.
That’s why Pause() and Resume() call a pause handler before updating CurrentScreen. We’ll supply that handler from a component in the scene next.
Connect the menu to gameplay
Let’s give the game a pair of controls. Escape or the gamepad Start button will open the menu, and Space or the gamepad south button will add a point to the score. We’ll group these bindings in a Gameplay action map so we can disable them together when the menu opens.
Create Assets/Scripts/PauseMenuController.cs with:
using Loom;
using UnityEngine;
using UnityEngine.InputSystem;
public sealed class PauseMenuController : MonoBehaviour
{
private UIBridge _bridge;
private InputActionMap _gameplay;
private float _resumeTimeScale;
private bool _paused;
private void Start()
{
_bridge = UIBridge.Instance;
_gameplay = new InputActionMap("Gameplay");
var pause = _gameplay.AddAction("Pause", InputActionType.Button);
pause.AddBinding("<Keyboard>/escape");
pause.AddBinding("<Gamepad>/start");
pause.performed += _ => _bridge.Pause();
var score = _gameplay.AddAction("Score", InputActionType.Button);
score.AddBinding("<Keyboard>/space");
score.AddBinding("<Gamepad>/buttonSouth");
score.performed += _ => _bridge.Score += 1;
EnableGameplay();
}
private void OnEnable()
{
// OnEnable also runs before Start has created the action map.
if (_gameplay != null) EnableGameplay();
}
private void EnableGameplay()
{
_bridge.SetPauseHandler(SetPaused);
_bridge.Resume();
}
private void SetPaused(bool paused)
{
if (paused)
{
if (!_paused) _resumeTimeScale = Time.timeScale;
_gameplay.Disable();
Time.timeScale = 0;
LoomRuntime.KeyboardMode = LoomKeyboardMode.Navigation;
}
else
{
if (_paused) Time.timeScale = _resumeTimeScale;
LoomRuntime.KeyboardMode = LoomKeyboardMode.Gameplay;
_gameplay.Enable();
}
_paused = paused;
}
private void OnDisable()
{
if (_gameplay == null) return;
_bridge.Resume();
_bridge.SetPauseHandler(null);
_gameplay.Disable();
}
private void OnDestroy() => _gameplay?.Dispose();
}
Add this component to an empty GameObject named Pause Menu Controller in the same scene as Loom UI, then save the scene.
When you open the menu, SetPaused disables the Gameplay map and switches LoomRuntime.KeyboardMode to Navigation. Loom continues sending input to
the menu while the gameplay bindings are disabled.
We also save the current Time.timeScale before setting it to zero. This
pauses physics and code that uses scaled game time. Input and code using
unscaled time continue running. When you resume, we restore the saved time
scale, enable the Gameplay map, and switch keyboard handling back to Gameplay.
Save both scripts and let Unity finish compiling. Loom automatically updates the UI’s TypeScript declarations with the properties and actions we’ve added.
Render the HUD and menu
Now we can use CurrentScreen to choose what appears in the UI. Solid’s Switch and Match components let us show the HUD for Hud and the pause
menu for Pause. Each button will call the corresponding bridge action.
Open UI/src/App.tsx and replace its contents with:
import { createSignal, Match, Switch } from 'solid-js';
import { NavigationScope, useBridge } from '@loomgui/bridge';
export default function App() {
const bridge = useBridge();
const [error, setError] = createSignal('');
function run(action: () => Promise<void>) {
setError('');
void action().catch((cause: unknown) => setError(String(cause)));
}
return (
<main>
<Switch>
<Match when={bridge.currentScreen === 'Hud'}>
<div class="hud">
<p>Score: {bridge.score}</p>
<button onClick={() => run(() => bridge.pause())}>Pause</button>
</div>
</Match>
<Match when={bridge.currentScreen === 'Pause'}>
<div class="pause-backdrop">
<NavigationScope
name="pause"
defaultFocus="#resume"
backTarget="#resume"
class="pause-menu"
>
<h1>Paused</h1>
<p>Score: {bridge.score}</p>
<button id="resume" onClick={() => run(() => bridge.resume())}>
Resume
</button>
<button id="reset-score" onClick={() => run(() => bridge.resetScore())}>
Reset score
</button>
</NavigationScope>
</div>
</Match>
</Switch>
<p class="error" role="alert">{error()}</p>
</main>
);
}
In TypeScript, CurrentScreen becomes currentScreen, and the enum values
are the strings 'Hud' and 'Pause'. When C# changes that property, Solid
updates the matching branch and displays the new screen.
The buttons call their actions through run, which catches failed calls and
displays the error at the bottom of the screen.
Enable menu navigation
The buttons are in place. Let’s make them work with the arrow keys and gamepad.
We’ve wrapped the pause menu in a NavigationScope to group its buttons. To
handle navigation within that group, wrap App in a NavigationProvider.
Replace UI/src/index.tsx with:
import { render } from 'solid-js/web';
import { BridgeProvider, NavigationProvider } from '@loomgui/bridge';
import App from './App';
import { bridge } from './bridge';
import './styles.css';
const root = document.getElementById('root');
if (!root) throw new Error('Missing #root element');
render(() => (
<BridgeProvider value={bridge}>
<NavigationProvider>
<App />
</NavigationProvider>
</BridgeProvider>
), root);
Keep the provider above App so it stays mounted when the displayed screen
changes. Navigation becomes active when the pause menu’s scope appears.
Back in App.tsx, defaultFocus="#resume" selects Resume the first time
the menu opens. Setting backTarget to the same button lets you activate it
with Escape or the gamepad east button. If you move to Reset score, the
named scope remembers that button and focuses it when you reopen the menu.
Style the menu
Next, we’ll give the HUD a bar across the top of the screen and center the pause menu on a dark backdrop. We’ll also add a focus outline so you can see which button is selected as you move through the menu.
Replace UI/src/styles.css with:
html, body, #root {
margin: 0;
min-height: 100%;
background: transparent;
}
body {
color: #f3f4f6;
font-family: system-ui, sans-serif;
}
button {
padding: 0.75rem 1.25rem;
border: 1px solid #64748b;
border-radius: 0.5rem;
background: #263449;
color: inherit;
font: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 3px solid #a5c8ff;
outline-offset: 4px;
}
.hud {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: #17202f;
}
.pause-backdrop {
display: grid;
place-items: center;
min-height: 100vh;
background: rgb(0 0 0 / 60%);
}
.pause-menu {
display: grid;
gap: 1rem;
width: min(18rem, 75vw);
padding: 2rem;
border-radius: 1rem;
background: #17202f;
}
.pause-menu h1, .pause-menu p {
margin: 0;
}
.error:not(:empty) {
position: fixed;
bottom: 1rem;
left: 1rem;
right: 1rem;
padding: 1rem;
background: #7f1d1d;
}
Update the browser mocks
We’ve changed the C# bridge, so the browser preview needs the new state and
actions too. Open UI/src/bridge.ts and replace its contents with:
import { bootBridge } from '@loomgui/bridge';
export const bridge = bootBridge({
initial: { currentScreen: 'Hud', score: 0 },
mockActions: ({ state }) => ({
pause: () => { state.currentScreen = 'Pause'; },
resume: () => { state.currentScreen = 'Hud'; },
resetScore: () => { state.score = 0; },
}),
});
These functions let you open and close the menu and reset the score in a
standalone browser preview. When the UI runs in Unity, the buttons call your
C# actions instead.
You can read more about how the connection is selected in bootBridge.
Try the menu in Unity
Before running the menu, open a terminal in UI/ and check that the UI code
matches the generated types:
npm run typecheck
If the UI dev server is still running from the earlier tutorial, use Tools > Loom > Restart Dev Server to load the changed bridge.
- Enter Play mode and focus the Game view. You’ll see Score: 0 and a Pause button.
- Press Space or the gamepad south button twice to bring the score to 2.
- Press Escape or the gamepad Start button. The pause menu appears with Resume focused.
- Press Down or move the D-pad down to select Reset score, then press Enter or the gamepad south button. The score returns to 0 and the menu stays open. The same gamepad button added points earlier; now it only activates the menu button because the gameplay bindings are disabled.
- Press Escape or the gamepad east button to resume. Press Space or the gamepad south button again. The score increases to 1.
- Open the menu once more. Reset score has focus because it was the last selected button. Click Resume to return to the HUD with the mouse.
If your scene has movement driven by Time.deltaTime or physics, check that
it stops while the menu is open and continues when you resume. The menu keeps
responding to input throughout the pause. Repeat these checks in a player
build to test the menu outside the Editor.
Preview in a browser
You can also work on the menu while Unity is stopped. Run npm run dev from UI/ and open the URL in the terminal. Click Pause, use the arrow keys to
move between the buttons, and press Escape to resume.
This preview uses the mock actions you added earlier. Use it to adjust the layout and navigation, then return to Unity to check gameplay input and game time.
If the menu doesn’t work
If the HUD never appears, check the Unity Console for compilation errors and
compare UIBridge.cs with the code above. An action error saying PauseMenuController is not active. means the bridge has no pause handler;
check that Pause Menu Controller is active and its component is enabled.
For focus or controller problems, see Troubleshooting navigation.
Source files
You can download the complete files from this tutorial here:
| File in your project | Download |
|---|---|
Assets/Scripts/UIBridge.cs | UIBridge.cs |
Assets/Scripts/PauseMenuController.cs | PauseMenuController.cs |
UI/src/App.tsx | App.tsx |
UI/src/index.tsx | index.tsx |
UI/src/bridge.ts | bridge.ts |
UI/src/styles.css | styles.css |
When you’re ready to add nested menus or customize how focus moves between controls, continue with Keyboard and gamepad navigation.