Table of Contents

Building a game menu

This guide builds the menus of a small game: a main menu (Play, Settings, Quit), a pause menu (Resume, Settings, Quit to menu), and a settings page where the player changes the UI size. The settings follow the rule for anything that changes how the screen looks: nothing applies while the player drags, Apply commits, and a change that isn't confirmed within ten seconds reverts, so a bad size can never leave the UI unusable.

You need a game with IcyUI set up and one screen on it: finish Getting started and Your first screen first. The menus are pages of one Frame, which navigates between them and keeps a back history; see Tabs and navigation.

1. Describe the pages in markup

Each page is a file under Menus/ in your game project, with Copy to Output Directory set to "Copy if newer":

<ItemGroup>
  <None Include="Menus\*.xml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

The main menu is a Page whose buttons run commands on the view model:

<!-- Menus/MainMenu.xml. The data context is the MenuViewModel. KeepAlive lets Back return to this page. -->
<Page KeepAlive="True">
  <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
    <TextBlock FontSize="32" Margin="0,0,0,24" HorizontalAlignment="Center" Text="{Binding Title}" />
    <Button x:Name="PlayButton" Padding="24,8" Margin="0,0,0,8" Command="{Binding PlayCommand}">Play</Button>
    <Button Padding="24,8" Margin="0,0,0,8" Command="{Binding SettingsCommand}">Settings</Button>
    <Button Padding="24,8" Command="{Binding QuitCommand}">Quit</Button>
  </StackPanel>
</Page>

KeepAlive makes the frame keep the page when the player opens Settings, so Back returns to the same page. The pause menu is the same shape:

<!-- Menus/PauseMenu.xml. The same data context as the main menu. -->
<Page KeepAlive="True">
  <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
    <TextBlock FontSize="32" Margin="0,0,0,24" HorizontalAlignment="Center">Paused</TextBlock>
    <Button x:Name="ResumeButton" Padding="24,8" Margin="0,0,0,8" Command="{Binding ResumeCommand}">Resume</Button>
    <Button Padding="24,8" Margin="0,0,0,8" Command="{Binding SettingsCommand}">Settings</Button>
    <Button x:Name="QuitToMenuButton" Padding="24,8" Command="{Binding QuitToMenuCommand}">Quit to menu</Button>
  </StackPanel>
</Page>

2. Write the view models

Two view models drive the pages. MenuViewModel holds the commands. Starting, resuming and quitting are the game's business, so they're events; opening Settings and going back are the menu's own, and use the frame's Navigation:

/// <summary>
/// The data behind every menu page: the commands the pages bind to. Starting, resuming and quitting are the game's
/// business, so they're events; opening Settings and going back are the menu's own.
/// </summary>
public sealed class MenuViewModel : ObservableObject
{
    private readonly Frame frame;

    public MenuViewModel(Frame frame, SettingsViewModel settings)
    {
        this.frame = frame;
        Settings = settings;
        settings.BackRequested += (_, _) => GoBack();
        PlayCommand = new RelayCommand(() => PlayRequested?.Invoke(this, EventArgs.Empty));
        ResumeCommand = new RelayCommand(() => ResumeRequested?.Invoke(this, EventArgs.Empty));
        QuitToMenuCommand = new RelayCommand(() => QuitToMenuRequested?.Invoke(this, EventArgs.Empty));
        QuitCommand = new RelayCommand(() => QuitRequested?.Invoke(this, EventArgs.Empty));
        SettingsCommand = new RelayCommand(OpenSettings);
    }

    public event EventHandler? PlayRequested;

    public event EventHandler? ResumeRequested;

    public event EventHandler? QuitToMenuRequested;

    public event EventHandler? QuitRequested;

    public string Title => "My Game";

    public SettingsViewModel Settings { get; }

    public ICommand PlayCommand { get; }

    public ICommand ResumeCommand { get; }

    public ICommand QuitToMenuCommand { get; }

    public ICommand QuitCommand { get; }

    public ICommand SettingsCommand { get; }

    public bool IsOpen => frame.IsVisible;

    public void ShowMainMenu() => Show("Menus/MainMenu.xml");

    public void ShowPauseMenu() => Show("Menus/PauseMenu.xml");

    /// <summary>Hides the menu, dropping any change that was never confirmed.</summary>
    public void Close()
    {
        Settings.Revert();
        frame.Navigation.Clear();
        frame.IsVisible = false;
    }

    /// <summary>Goes back one page, dropping an unconfirmed change. Returns false on the first page.</summary>
    public bool GoBack()
    {
        Settings.Revert();
        return frame.Navigation.TryNavigateBack() != null;
    }

    private void OpenSettings()
    {
        // Bindings follow their data context, not what is nested inside it: the page binds to the settings itself.
        if (frame.Navigation.Navigate("Menus/Settings.xml") is { } page)
            page.DataContext = Settings;
    }

    private void Show(string page)
    {
        Settings.Revert();
        frame.Navigation.Clear();
        frame.IsVisible = true;
        frame.Navigation.Navigate(page);
    }
}

ShowMainMenu and ShowPauseMenu clear the frame's history and navigate to a page by its path; Navigate(string) loads the markup through the asset pipeline.

3. Add the settings page with Apply and Cancel

The settings page edits a draft: the slider is bound two-way to DraftScale, and nothing else changes until Apply.

<!-- Menus/xml. The data context is the SettingsViewModel: the menu sets it on the page when it opens. -->
<Page>
  <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical" Width="320">
    <TextBlock FontSize="28" Margin="0,0,0,16">Settings</TextBlock>

    <TextBlock Margin="0,0,0,4">UI size</TextBlock>
    <Slider x:Name="UiSize" Minimum="0.5" Maximum="2" Margin="0,0,0,8" Value="{Binding DraftScale, Mode=TwoWay}" />
    <StackPanel Orientation="Horizontal" Margin="0,0,0,16">
      <Button x:Name="ApplyButton" Padding="16,6" Margin="0,0,8,0" Command="{Binding ApplyCommand}">Apply</Button>
      <Button x:Name="CancelButton" Padding="16,6" Command="{Binding CancelCommand}">Cancel</Button>
    </StackPanel>

    <!-- Shown for ten seconds after Apply; the change reverts unless the player keeps it. -->
    <StackPanel x:Name="ConfirmPanel" Orientation="Vertical" Margin="0,0,0,16" IsVisible="{Binding IsConfirming}">
      <TextBlock Text="{Binding CountdownText}" Margin="0,0,0,4" />
      <ProgressBar Minimum="0" Maximum="1" Height="6" Margin="0,0,0,8" Value="{Binding RevertProgress}" />
      <StackPanel Orientation="Horizontal">
        <Button x:Name="KeepButton" Padding="16,6" Margin="0,0,8,0" Command="{Binding KeepCommand}">Keep</Button>
        <Button x:Name="RevertButton" Padding="16,6" Command="{Binding RevertCommand}">Revert</Button>
      </StackPanel>
    </StackPanel>

    <Button x:Name="BackButton" Padding="16,6" HorizontalAlignment="Left" Command="{Binding BackCommand}">Back</Button>
  </StackPanel>
</Page>

This page binds to SettingsViewModel directly, not through MenuViewModel.Settings. A binding follows its data context and nothing further along the path, so OpenSettings in the view model above sets the page's DataContext to the settings object when it navigates. See Data binding.

The settings view model applies the draft to UserScale, and puts the previous value back unless it's kept in time:

/// <summary>
/// The settings page's data. The slider edits a draft; Apply commits it, and unless the player confirms within ten
/// seconds, the previous size comes back, so a bad size can never leave the UI unusable.
/// </summary>
public sealed class SettingsViewModel : ObservableObject
{
    private const float RevertSeconds = 10;

    private readonly ScalingConfiguration scaling;
    private float draftScale;
    private float previousScale;
    private float remaining;
    private bool isConfirming;

    public SettingsViewModel(ScalingConfiguration scaling)
    {
        this.scaling = scaling;
        draftScale = scaling.UserScale;
        ApplyCommand = new RelayCommand(Apply);
        CancelCommand = new RelayCommand(Cancel);
        KeepCommand = new RelayCommand(Keep);
        RevertCommand = new RelayCommand(Revert);
        BackCommand = new RelayCommand(() => BackRequested?.Invoke(this, EventArgs.Empty));
    }

    /// <summary>Raised when the player presses Back; the menu goes to the previous page.</summary>
    public event EventHandler? BackRequested;

    /// <summary>The slider's value. Nothing changes on screen until Apply.</summary>
    public float DraftScale
    {
        get => draftScale;
        set => SetProperty(ref draftScale, value);
    }

    /// <summary>True while an applied change waits for the player to keep or revert it.</summary>
    public bool IsConfirming
    {
        get => isConfirming;
        private set => SetProperty(ref isConfirming, value);
    }

    /// <summary>The time left, from 1 down to 0, for the countdown bar.</summary>
    public float RevertProgress => remaining / RevertSeconds;

    public string CountdownText => $"Keep this size? Reverting in {MathF.Ceiling(remaining)} s";

    public ICommand ApplyCommand { get; }

    public ICommand CancelCommand { get; }

    public ICommand KeepCommand { get; }

    public ICommand RevertCommand { get; }

    public ICommand BackCommand { get; }

    /// <summary>Advances the countdown. Call it every frame with the frame time, even while the game is paused.</summary>
    public void Tick(float elapsedSeconds)
    {
        if (!IsConfirming)
            return;

        remaining -= elapsedSeconds;
        if (remaining <= 0)
            Revert();
        else
            OnCountdownChanged();
    }

    /// <summary>Puts the previous size back, if a change is waiting for confirmation.</summary>
    public void Revert()
    {
        if (!IsConfirming)
            return;

        scaling.UserScale = previousScale;
        DraftScale = previousScale;
        Finish();
    }

    private void Apply()
    {
        if (IsConfirming)
            return;

        previousScale = scaling.UserScale;
        scaling.UserScale = DraftScale;
        remaining = RevertSeconds;
        IsConfirming = true;
        OnCountdownChanged();
    }

    private void Keep() => Finish();

    private void Cancel() => DraftScale = scaling.UserScale;

    private void Finish()
    {
        remaining = 0;
        IsConfirming = false;
        OnCountdownChanged();
    }

    private void OnCountdownChanged()
    {
        OnPropertyChanged(nameof(RevertProgress));
        OnPropertyChanged(nameof(CountdownText));
    }
}

The countdown is plain code: Tick takes the frame time. Call it every frame, including while the game is paused, because the player is in the pause menu when the countdown matters. Leaving the page, or closing the menu, also reverts an unconfirmed change.

4. Show the menu and pause the game

MenuController ties the pieces together: it adds the frame to the main canvas, wires the events, and handles Escape with KeyGesture. Escape pauses during play; in a menu it goes back one page; on the first page of the pause menu it resumes.

/// <summary>
/// Owns the menu and the game's play/pause state. Engine-neutral: the game's update loop calls <see cref="Update"/>
/// and checks <see cref="IsPaused"/>.
/// </summary>
public sealed class MenuController
{
    public MenuController(IIcyHost ui)
    {
        Frame = new Frame
        {
            HorizontalAlignment = HorizontalAlignment.Stretch,
            VerticalAlignment = VerticalAlignment.Stretch,
        };
        ui.MainCanvas.Add(Frame);

        Menu = new MenuViewModel(Frame, new SettingsViewModel(ui.Configuration.Scaling));
        Frame.DataContext = Menu;

        Menu.PlayRequested += (_, _) => StartPlaying();
        Menu.ResumeRequested += (_, _) => Resume();
        Menu.QuitToMenuRequested += (_, _) => QuitToMenu();
        Menu.QuitRequested += (_, _) => QuitRequested?.Invoke(this, EventArgs.Empty);
        ui.Configuration.Input.Events.RegisterCommand(new RelayCommand(HandleEscape), new KeyGesture(Keys.Escape));

        Menu.ShowMainMenu();
    }

    /// <summary>Raised when the player chooses Quit; the game exits.</summary>
    public event EventHandler? QuitRequested;

    public Frame Frame { get; }

    public MenuViewModel Menu { get; }

    public bool IsPlaying { get; private set; }

    /// <summary>True while the pause menu is open: gameplay should skip its update.</summary>
    public bool IsPaused { get; private set; }

    /// <summary>Call every frame with the frame time. The Settings countdown runs even while paused.</summary>
    public void Update(float elapsedSeconds) => Menu.Settings.Tick(elapsedSeconds);

    /// <summary>Escape: pause during play, go back a page in a menu, or resume from the pause menu's first page.</summary>
    public void HandleEscape()
    {
        if (!Menu.IsOpen)
        {
            if (IsPlaying)
                Pause();
        }
        else if (!Menu.GoBack() && IsPaused)
        {
            Resume();
        }
    }

    private void StartPlaying()
    {
        IsPlaying = true;
        IsPaused = false;
        Menu.Close();
    }

    private void Pause()
    {
        IsPaused = true;
        Menu.ShowPauseMenu();
    }

    private void Resume()
    {
        IsPaused = false;
        Menu.Close();
    }

    private void QuitToMenu()
    {
        IsPlaying = false;
        IsPaused = false;
        Menu.ShowMainMenu();
    }
}

Nothing in it refers to an engine, so the same code runs on both. What differs is the game loop, which calls Update and skips gameplay while IsPaused is set:

using Icy.Stride.Configuration;
using Stride.Engine;

/// <summary>Add this script to an entity in your scene: it shows the main menu and pauses gameplay behind the pause menu.</summary>
public class MenuScript : SyncScript
{
    private MenuController menu = null!;

    public override void Start()
    {
        StrideIcyHost ui = Game.UseIcyUI();
        menu = new MenuController(ui);
        menu.QuitRequested += (_, _) => ((Game)Game).Exit();
    }

    public override void Update()
    {
        // UI time: the Settings countdown must keep running while the game is paused.
        menu.Update((float)Game.UpdateTime.Elapsed.TotalSeconds);

        if (!menu.IsPlaying || menu.IsPaused)
            return;

        // Your gameplay update goes here.
    }
}

"Paused" is your flag, not the engine's: IcyUI keeps drawing and animating the menu, since it's a separate layer from your gameplay. Skip your own updates (movement, timers, AI) while it's set.

What you built

A main menu, a pause menu and a settings page in markup, behind one frame with back history, and a controller that starts, pauses and resumes the game. The UI size follows Apply and Cancel, and an unconfirmed change reverts after ten seconds, whatever the player does next.

Note

The gamepad's X and Y raise back and forward events, but a Frame doesn't act on them yet, so every page has a Back button. Escape (and B, which closes dialogs) doesn't go back a page by itself either: this guide's HandleEscape does that, for the keyboard.

Next