Interaction & UI
Creating a Menu System
A menu is the first interactive screen many players see. This tutorial builds a reusable MonoGame menu with a vertical List<MenuItem>, keyboard and gamepad navigation, mouse hit-testing, submenu support, and actions that switch game state. By the end you will have a runnable main menu with Start, Options, and Quit.
Download the runnable sample project
Use the verified ZIP bundle for this article to review the extracted example files, run the sample host, and repackage the sources without bin or obj folders.
Menu items and callbacks
Each menu entry needs two pieces of information: the text shown to the player and the code that runs when the entry is chosen. A small MenuItem class keeps those together. The Action callback can start the game, open another menu, or call Exit.
using System;
using Microsoft.Xna.Framework;
public sealed class MenuItem
{
public MenuItem(string label, Action onSelected)
{
Label = label;
OnSelected = onSelected;
}
public string Label { get; }
public Action OnSelected { get; }
public Rectangle Bounds { get; set; }
public void Activate()
{
OnSelected?.Invoke();
}
}
💡 Keep actions small. They should change state, push a submenu, or request exit; avoid loading large content or doing expensive work directly inside a menu callback.
Edge-detected input
Menus should move once per press, not every frame while a key is held. Store the previous and current keyboard, gamepad, and mouse states, then compare them. Up and Down change selectedIndex; Enter or gamepad A activates the selected item.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
public sealed class InputState
{
private KeyboardState _currentKeyboard;
private KeyboardState _previousKeyboard;
private MouseState _currentMouse;
private MouseState _previousMouse;
private GamePadState _currentGamePad;
private GamePadState _previousGamePad;
public Point MousePosition => new Point(_currentMouse.X, _currentMouse.Y);
public void Update()
{
_previousKeyboard = _currentKeyboard;
_previousMouse = _currentMouse;
_previousGamePad = _currentGamePad;
_currentKeyboard = Keyboard.GetState();
_currentMouse = Mouse.GetState();
_currentGamePad = GamePad.GetState(PlayerIndex.One);
}
public bool WasMenuUpPressed() =>
WasKeyPressed(Keys.Up) || WasButtonPressed(Buttons.DPadUp) || WasLeftStickUpPressed();
public bool WasMenuDownPressed() =>
WasKeyPressed(Keys.Down) || WasButtonPressed(Buttons.DPadDown) || WasLeftStickDownPressed();
public bool WasMenuSelectPressed() =>
WasKeyPressed(Keys.Enter) || WasButtonPressed(Buttons.A);
public bool WasLeftMouseClicked() =>
_currentMouse.LeftButton == ButtonState.Pressed &&
_previousMouse.LeftButton == ButtonState.Released;
private bool WasKeyPressed(Keys key) =>
_currentKeyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
private bool WasButtonPressed(Buttons button) =>
_currentGamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
private bool WasLeftStickUpPressed() =>
_currentGamePad.ThumbSticks.Left.Y > 0.5f &&
_previousGamePad.ThumbSticks.Left.Y <= 0.5f;
private bool WasLeftStickDownPressed() =>
_currentGamePad.ThumbSticks.Left.Y < -0.5f &&
_previousGamePad.ThumbSticks.Left.Y >= -0.5f;
}
⚠️ Warning: Do not use Keyboard.GetState().IsKeyDown(Keys.Down) directly for menu movement. At 60 frames per second a held key can skip several entries before the player reacts.
Mouse support and hit-testing
Mouse support uses the same entries. During update, calculate a Rectangle for each row, compare it with the mouse position, and select the row under the pointer. If the left button changed from released to pressed while inside that row, activate it.
for (int i = 0; i < _items.Count; i++)
{
Rectangle bounds = new Rectangle(
(int)_position.X,
(int)_position.Y + i * _itemHeight,
_width,
_itemHeight);
_items[i].Bounds = bounds;
if (bounds.Contains(input.MousePosition))
{
_selectedIndex = i;
if (input.WasLeftMouseClicked())
_items[i].Activate();
}
}
💡 The mouse and keyboard should share one selected index. That keeps the visual highlight consistent whether the player uses keys, a controller, or the pointer.
Reusable Menu class
The reusable class owns the List<MenuItem>, tracks selectedIndex, updates navigation, and draws labels with a SpriteFont. It receives a 1×1 pixel texture in its constructor and stretches it to draw highlight bars, so menu shapes do not require any image asset.
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
public sealed class Menu
{
private readonly List<MenuItem> _items = new();
private readonly Vector2 _position;
private readonly int _width;
private readonly int _itemHeight;
private readonly Texture2D _highlightPixel;
private int _selectedIndex;
public Menu(Vector2 position, int width, int itemHeight, Texture2D highlightPixel)
{
_position = position;
_width = width;
_itemHeight = itemHeight;
_highlightPixel = highlightPixel;
}
public void Add(string label, Action onSelected)
{
_items.Add(new MenuItem(label, onSelected));
}
public void Update(InputState input)
{
if (_items.Count == 0)
return;
if (input.WasMenuUpPressed())
_selectedIndex = (_selectedIndex - 1 + _items.Count) % _items.Count;
if (input.WasMenuDownPressed())
_selectedIndex = (_selectedIndex + 1) % _items.Count;
UpdateMouseSelection(input);
if (input.WasMenuSelectPressed())
_items[_selectedIndex].Activate();
}
public void Draw(SpriteBatch spriteBatch, SpriteFont font)
{
for (int i = 0; i < _items.Count; i++)
{
Rectangle bounds = GetBounds(i);
Color background = i == _selectedIndex ? Color.DarkSlateBlue : Color.Black * 0.45f;
Color foreground = i == _selectedIndex ? Color.White : Color.LightGray;
spriteBatch.Draw(_highlightPixel, bounds, background);
spriteBatch.DrawString(font, _items[i].Label, new Vector2(bounds.X + 16, bounds.Y + 12), foreground);
}
}
private void UpdateMouseSelection(InputState input)
{
for (int i = 0; i < _items.Count; i++)
{
Rectangle bounds = GetBounds(i);
_items[i].Bounds = bounds;
if (bounds.Contains(input.MousePosition))
{
_selectedIndex = i;
if (input.WasLeftMouseClicked())
_items[i].Activate();
}
}
}
private Rectangle GetBounds(int index) =>
new Rectangle((int)_position.X, (int)_position.Y + index * _itemHeight, _width, _itemHeight - 6);
}
⚠️ Caution: SpriteBatch.DrawString needs a built .spritefont asset. Add a file such as Content/MenuFont.spritefont to the Content Pipeline and load it with Content.Load<SpriteFont>("MenuFont"). The generated pixel only replaces image files for rectangles and highlight bars.
Submenus and a menu stack
A stack makes nested menus straightforward. The main menu can push an Options menu; Back pops it and returns control to the previous menu. This keeps the flow Main → Options → Back without hard-coding every possible screen transition.
private readonly Stack<Menu> _menus = new();
private void BuildMenus()
{
_mainMenu = new Menu(new Vector2(80, 110), 320, 54, _pixel);
_optionsMenu = new Menu(new Vector2(80, 110), 320, 54, _pixel);
_mainMenu.Add("Start", () =>
{
_screen = GameScreen.Playing;
_menus.Clear();
});
_mainMenu.Add("Options", () =>
{
_screen = GameScreen.OptionsMenu;
_menus.Push(_optionsMenu);
});
_mainMenu.Add("Quit", Exit);
_optionsMenu.Add("Back", () =>
{
_screen = GameScreen.MainMenu;
_menus.Pop();
});
_menus.Push(_mainMenu);
}
For larger projects, connect this menu layer to the state approach in Game States. Menu actions should request state changes such as Start -> Playing rather than mixing gameplay and menu logic together.
Complete example: main menu to game state
This complete Game1.cs creates a main menu with Start, Options, and Quit. Start switches to a simple playing state with a generated rectangle. Options opens a submenu with Back. Keyboard, gamepad, and mouse all use the same selection and activation logic.
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
public sealed class Game1 : Game
{
private enum GameScreen
{
MainMenu,
OptionsMenu,
Playing
}
private readonly GraphicsDeviceManager _graphics;
private readonly InputState _input = new();
private readonly Stack<Menu> _menus = new();
private SpriteBatch _spriteBatch;
private SpriteFont _font;
private Texture2D _pixel;
private Menu _mainMenu;
private Menu _optionsMenu;
private GameScreen _screen = GameScreen.MainMenu;
private Vector2 _playerPosition = new Vector2(120, 180);
public Game1()
{
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
IsMouseVisible = true;
}
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
_font = Content.Load<SpriteFont>("MenuFont");
_pixel = new Texture2D(GraphicsDevice, 1, 1);
_pixel.SetData(new[] { Color.White });
BuildMenus();
}
protected override void Update(GameTime gameTime)
{
_input.Update();
if (_screen == GameScreen.Playing)
{
UpdatePlaying(gameTime);
base.Update(gameTime);
return;
}
if (_menus.Count > 0)
_menus.Peek().Update(_input);
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
_spriteBatch.Begin();
if (_screen == GameScreen.Playing)
DrawPlaying();
else if (_menus.Count > 0)
_menus.Peek().Draw(_spriteBatch, _font);
_spriteBatch.End();
base.Draw(gameTime);
}
private void BuildMenus()
{
_mainMenu = new Menu(new Vector2(80, 110), 320, 54, _pixel);
_optionsMenu = new Menu(new Vector2(80, 110), 320, 54, _pixel);
_mainMenu.Add("Start", () =>
{
_screen = GameScreen.Playing;
_menus.Clear();
});
_mainMenu.Add("Options", () =>
{
_screen = GameScreen.OptionsMenu;
_menus.Push(_optionsMenu);
});
_mainMenu.Add("Quit", Exit);
_optionsMenu.Add("Back", () =>
{
_screen = GameScreen.MainMenu;
_menus.Pop();
});
_menus.Push(_mainMenu);
}
private void UpdatePlaying(GameTime gameTime)
{
if (Keyboard.GetState().IsKeyDown(Keys.Escape))
{
_screen = GameScreen.MainMenu;
_menus.Clear();
_menus.Push(_mainMenu);
return;
}
float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
KeyboardState keyboard = Keyboard.GetState();
Vector2 movement = Vector2.Zero;
if (keyboard.IsKeyDown(Keys.Left)) movement.X -= 1f;
if (keyboard.IsKeyDown(Keys.Right)) movement.X += 1f;
if (keyboard.IsKeyDown(Keys.Up)) movement.Y -= 1f;
if (keyboard.IsKeyDown(Keys.Down)) movement.Y += 1f;
if (movement != Vector2.Zero)
movement.Normalize();
_playerPosition += movement * 180f * dt;
}
private void DrawPlaying()
{
_spriteBatch.Draw(_pixel, new Rectangle((int)_playerPosition.X, (int)_playerPosition.Y, 48, 48), Color.OrangeRed);
_spriteBatch.DrawString(_font, "Playing - press Escape to return to the menu", new Vector2(24, 24), Color.White);
}
}
public sealed class MenuItem
{
public MenuItem(string label, Action onSelected)
{
Label = label;
OnSelected = onSelected;
}
public string Label { get; }
public Action OnSelected { get; }
public Rectangle Bounds { get; set; }
public void Activate()
{
OnSelected?.Invoke();
}
}
public sealed class Menu
{
private readonly List<MenuItem> _items = new();
private readonly Vector2 _position;
private readonly int _width;
private readonly int _itemHeight;
private readonly Texture2D _highlightPixel;
private int _selectedIndex;
public Menu(Vector2 position, int width, int itemHeight, Texture2D highlightPixel)
{
_position = position;
_width = width;
_itemHeight = itemHeight;
_highlightPixel = highlightPixel;
}
public void Add(string label, Action onSelected)
{
_items.Add(new MenuItem(label, onSelected));
}
public void Update(InputState input)
{
if (_items.Count == 0)
return;
if (input.WasMenuUpPressed())
_selectedIndex = (_selectedIndex - 1 + _items.Count) % _items.Count;
if (input.WasMenuDownPressed())
_selectedIndex = (_selectedIndex + 1) % _items.Count;
UpdateMouseSelection(input);
if (input.WasMenuSelectPressed())
_items[_selectedIndex].Activate();
}
public void Draw(SpriteBatch spriteBatch, SpriteFont font)
{
for (int i = 0; i < _items.Count; i++)
{
Rectangle bounds = GetBounds(i);
Color background = i == _selectedIndex ? Color.DarkSlateBlue : Color.Black * 0.45f;
Color foreground = i == _selectedIndex ? Color.White : Color.LightGray;
spriteBatch.Draw(_highlightPixel, bounds, background);
spriteBatch.DrawString(font, _items[i].Label, new Vector2(bounds.X + 16, bounds.Y + 12), foreground);
}
}
private void UpdateMouseSelection(InputState input)
{
for (int i = 0; i < _items.Count; i++)
{
Rectangle bounds = GetBounds(i);
_items[i].Bounds = bounds;
if (bounds.Contains(input.MousePosition))
{
_selectedIndex = i;
if (input.WasLeftMouseClicked())
_items[i].Activate();
}
}
}
private Rectangle GetBounds(int index) =>
new Rectangle((int)_position.X, (int)_position.Y + index * _itemHeight, _width, _itemHeight - 6);
}
public sealed class InputState
{
private KeyboardState _currentKeyboard;
private KeyboardState _previousKeyboard;
private MouseState _currentMouse;
private MouseState _previousMouse;
private GamePadState _currentGamePad;
private GamePadState _previousGamePad;
public Point MousePosition => new Point(_currentMouse.X, _currentMouse.Y);
public void Update()
{
_previousKeyboard = _currentKeyboard;
_previousMouse = _currentMouse;
_previousGamePad = _currentGamePad;
_currentKeyboard = Keyboard.GetState();
_currentMouse = Mouse.GetState();
_currentGamePad = GamePad.GetState(PlayerIndex.One);
}
public bool WasMenuUpPressed() =>
WasKeyPressed(Keys.Up) || WasButtonPressed(Buttons.DPadUp) || WasLeftStickUpPressed();
public bool WasMenuDownPressed() =>
WasKeyPressed(Keys.Down) || WasButtonPressed(Buttons.DPadDown) || WasLeftStickDownPressed();
public bool WasMenuSelectPressed() =>
WasKeyPressed(Keys.Enter) || WasButtonPressed(Buttons.A);
public bool WasLeftMouseClicked() =>
_currentMouse.LeftButton == ButtonState.Pressed &&
_previousMouse.LeftButton == ButtonState.Released;
private bool WasKeyPressed(Keys key) =>
_currentKeyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
private bool WasButtonPressed(Buttons button) =>
_currentGamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
private bool WasLeftStickUpPressed() =>
_currentGamePad.ThumbSticks.Left.Y > 0.5f &&
_previousGamePad.ThumbSticks.Left.Y <= 0.5f;
private bool WasLeftStickDownPressed() =>
_currentGamePad.ThumbSticks.Left.Y < -0.5f &&
_previousGamePad.ThumbSticks.Left.Y >= -0.5f;
}
Run the project after adding MenuFont.spritefont to the Content Pipeline. Use the keyboard, a gamepad, or the mouse to move through the menu, open Options, return with Back, start the playing state, and press Escape to return to the menu.
dotnet build
dotnet run
Common pitfalls
- Skipping edge detection. Held keys or controller buttons will race through the menu.
- Forgetting mouse rectangles. Drawn text alone is not enough; calculate predictable hit boxes for every item.
- Loading a font inside
Draw. Load theSpriteFontonce inLoadContent. - Changing state inside drawing code. Actions and transitions belong in
Updatethrough callbacks. - Letting a submenu orphan the main menu. Use a stack so Back always returns to the previous menu.
⚠️ Caution: If your menu text does not appear, check that the .spritefont file is included in the Content Pipeline and that its asset name matches the string passed to Content.Load<SpriteFont>.
Next steps
🎓 Test Your Knowledge
Check your understanding of the key concepts from this tutorial.
What is the simplest data structure for a list of menu items?
✔ Correct!
✘ Not quite.
string[] items = { "Play", "Options", "Quit" }; int _selected = 0;
How should you move the menu selection down with the keyboard?
✔ Correct!
✘ Not quite.
if (downPressed) _selected = Math.Min(_selected + 1, _items.Length - 1); prevents going past the last item.
How do you highlight the currently selected menu item differently from others?
✔ Correct!
✘ Not quite.
Color c = i == _selected ? Color.Yellow : Color.White; spriteBatch.DrawString(_font, _items[i], pos, c);
What is a transition “easing” in the context of menu animations?
✔ Correct!
✘ Not quite.
e.g. ease-out: t = 1 - (1-t)*(1-t) gives a value that starts fast and slows to a stop.