Today, we are going to learn about UIs in Bevy. Our main goal is to end up with a main menu that contains 2 buttons, one to launch our game, and another to exit the application. This article will explain how to render UI elements to the screen, and how to listen to button events.
Nodes and Styles
We could easily compare Bevy's UI to HTML: you create a hierarchy of elements / nodes, apply some styles to them, and Bevy's engine takes care of rendering what you asked for. A few "bundles" are exposed by Bevy to help you build your UI:
- NodeBundle: a basic node
fn root(materials: &Res<MenuMaterials>) -> NodeBundle {
NodeBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
material: materials.root.clone(),
..Default::default()
}
}
- ButtonBundle: to render a button
fn button(materials: &Res<MenuMaterials>) -> ButtonBundle {
ButtonBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
material: materials.button.clone(),
..Default::default()
}
}
- TextBundle: to render text
fn button_text(asset_server: &Res<AssetServer>, materials: &Res<MenuMaterials>, label: &str) -> TextBundle {
return TextBundle {
style: Style {
margin: Rect::all(Val::Px(10.0)),
..Default::default()
},
text: Text::with_section(
label,
TextStyle {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 30.0,
color: materials.button_text.clone(),
},
Default::default(),
),
..Default::default()
};
}
Each of these element have a style property used to express how to render the node.
This style property is a Rust struct that contains a subset of the usual properties found in CSS. Among others, you should find:
- margin and padding: to add space around our UI elements
- position_type: for relative or absolute positioning
- most of the flexbox properties: Bevy's rendering engine implements flexbox for positioning
Our menu also includes these two nodes:
fn border(materials: &Res<MenuMaterials>) -> NodeBundle {
NodeBundle {
style: Style {
size: Size::new(Val::Px(400.0), Val::Auto),
border: Rect::all(Val::Px(8.0)),
..Default::default()
},
material: materials.border.clone(),
..Default::default()
}
}
fn menu_background(materials: &Res<MenuMaterials>) -> NodeBundle {
NodeBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::ColumnReverse,
padding: Rect::all(Val::Px(5.0)),
..Default::default()
},
material: materials.menu.clone(),
..Default::default()
}
}
Text Styles
The TextBundle, in addition to the style property, specifies a property called text which also has a style property. This one exposes the possibility to modify the text font size, color and font.
If you take a look to the text node above, you should notice the use of a Res<AssetServer>. This utility allows us to load non Rust resources such as images or fonts.
To use it, we creates a new folder to the root of our project and named it assets. When using the asset server to load a resource, give it the path of your resource relative to this folder. Ex:
asset_server.load("fonts/FiraSans-Bold.ttf")
Colors
Nodes and Buttons also contain a material property. This property works the same as the materials we previously added to our player and maps. To ease the development of our menu, we can centralize the definition of these colors into a resource:
struct MenuMaterials {
root: Handle<ColorMaterial>,
border: Handle<ColorMaterial>,
menu: Handle<ColorMaterial>,
button: Handle<ColorMaterial>,
button_hovered: Handle<ColorMaterial>,
button_pressed: Handle<ColorMaterial>,
button_text: Color,
}
impl FromWorld for MenuMaterials {
fn from_world(world: &mut World) -> Self {
let mut materials = world.get_resource_mut::<Assets<ColorMaterial>>().unwrap();
MenuMaterials {
root: materials.add(Color::NONE.into()),
border: materials.add(Color::rgb(0.65, 0.65, 0.65).into()),
menu: materials.add(Color::rgb(0.15, 0.15, 0.15).into()),
button: materials.add(Color::rgb(0.15, 0.15, 0.15).into()),
button_hovered: materials.add(Color::rgb(0.25, 0.25, 0.25).into()),
button_pressed: materials.add(Color::rgb(0.35, 0.75, 0.35).into()),
button_text: Color::WHITE,
}
}
}
We still need to tell Bevy to instantiate this resource:
// on our AppBuilder
.init_resource::<MenuMaterials>()
Defining the Node Hierarchy
We can add UI elements the same way we register other Bevy entities: we call AppBuilder.spawn_bundle and register children nodes with the with_children method:
enum MenuButton {
Play,
Quit,
}
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
materials: Res<MenuMaterials>,
) {
commands.spawn_bundle(UiCameraBundle::default());
commands
.spawn_bundle(root(&materials))
.with_children(|parent| {
// left vertical fill (border)
parent
.spawn_bundle(border(&materials))
.with_children(|parent| {
// left vertical fill (content)
parent
.spawn_bundle(menu_background(&materials))
.with_children(|parent| {
parent.spawn_bundle(button(&materials))
.with_children(|parent| {
parent.spawn_bundle(button_text(&asset_server, &materials, "New Game"));
})
.insert(MenuButton::Play);
parent.spawn_bundle(button(&materials))
.with_children(|parent| {
parent.spawn_bundle(button_text(&asset_server, &materials, "Quit"));
})
.insert(MenuButton::Quit);
});
});
});
}
Wiring it up
To wire everything up, we created a new Rust module called main_menu, which contains a MainMenuPlugin.
pub struct MainMenuPlugin;
impl Plugin for MainMenuPlugin {
fn build(&self, app: &mut AppBuilder) {
app.init_resource::<MenuMaterials>()
.add_system_set(
SystemSet::on_enter(AppState::MainMenu)
.with_system(cleanup.system())
.with_system(setup.system()),
)
.add_system_set(SystemSet::on_exit(AppState::MainMenu).with_system(cleanup.system()));
}
}
// main.rs
mod main_menu;
use main_menu::MainMenuPlugin;
// ...
.add_plugin(MainMenuPlugin)
Running the game:
Apply Different Styles on Interactions
It is possible to modify the button look with a system:
// MainMenuPlugin
.add_system(button_system.system())
// system implementation
fn button_system(
materials: Res<MenuMaterials>,
mut buttons: Query<
(&Interaction, &mut Handle<ColorMaterial>),
(Changed<Interaction>, With<Button>),
>
) {
for (interaction, mut material) in buttons.iter_mut() {
match *interaction {
Interaction::Clicked => *material = materials.button_pressed.clone(),
Interaction::Hovered => *material = materials.button_hovered.clone(),
Interaction::None => *material = materials.button.clone(),
}
}
}
This way, our buttons should change color when hovered, and turn to green when pressed.
Runnig Actions on Button Click
The same way we changed button styles, we can react to button click with a system triggered on clicks:
// main menu plugin
.add_system(button_press_system.system())
// System implementation
fn button_press_system(
buttons: Query<(&Interaction, &MenuButton), (Changed<Interaction>, With<Button>)>,
mut state: ResMut<State<AppState>>,
mut exit: EventWriter<AppExit>
) {
for (interaction, button) in buttons.iter() {
if *interaction == Interaction::Clicked {
match button {
MenuButton::Play => state
.set(AppState::InGame)
.expect("Couldn't switch state to InGame"),
MenuButton::Quit => exit.send(AppExit),
};
}
}
}
In order for this system to run, we need to ensure that a MenuButton component is added to our button. This system will run the action linked to the type of MenuButton that was added to our entity.
Here, pressing Play Game should start a new game, and pressing Quit should exit the game.
Final Bits of Code
Since we now have a menu that handles starting a new game, we can remove the main_menu_controls system that was defined in main.rs. Instead, we can add a new back_to_main_menu_controls in our game module:
// GamePlugin
.add_system_set(SystemSet::on_update(AppState::InGame).with_system(back_to_main_menu_controls.system()))
// System implementation
fn back_to_main_menu_controls(mut keys: ResMut<Input<KeyCode>>, mut app_state: ResMut<State<AppState>>) {
if *app_state.current() == AppState::InGame {
if keys.just_pressed(KeyCode::Escape) {
app_state.set(AppState::MainMenu).unwrap();
keys.reset(KeyCode::Escape);
}
}
}
All the code is available here. The most recent version of the game can be played here.


Top comments (3)
May I ask the question what is the difference between NodeBundle and ButtonBundle?
Shouldn't we use ButtonBundle here?
We do use it in the
buttonfunction. Or maybe you meant elsewhere?It is analog to the difference between a
divtag and abuttontag in HTML (if you are familiar with web development).Use the
NodeBundlewhenever you need something not much interactive.Use the
ButtonBundlewhenever you need a button / clickable thing.Sorry, I am new to Rust and Bevy, have not noticed that we use it. Thanks for the explanation!