Pong! update: Creating My Own Bevy Menu System

Unfortunately, I had less time to work on my Pong! clone this week, but I still managed to make some meaningful progress! The main achievement? Getting a proper menu system up and running in Bevy, plus fixing that annoying input lag that’s been bugging me.

My Journey with creating a Bevy Menu System

I focused on adding two menus to the game:

  • A main menu to navigate around
  • A settings menu (still pretty empty, but we’ll get there!)

While they might look pretty basic right now, I’m actually really happy with how they turned out. For a classic game like Pong!, sometimes simple is exactly what you want!

Pong! Main Menu created with my Bevy Menu System allowing to start the game, go to settings, or exit the game
Pong! settings menu created with my Bevy Menu System, only allowing to go back to main menu

Wrestling with Bevy’s UI Framework

I decided to use Bevy’s built-in UI system for the menus, which turned out to be quite a challenge. It uses an HTML and CSS-like approach for laying out your GUI, which gives you some flexibility. However, I quickly discovered it doesn’t come with common GUI widgets. You pretty much have to build everything from scratch!

My Own Bevy Menu System

Here’s the menu builder system I came up with:

#[derive(Default)]
struct MenuBuilder {
    style: Style,
    background_color: Option<Color>,
    buttons: Vec<MenuButton>,
    title: Option<String>,
    spacing: f32,
}

impl MenuBuilder {
    fn new() -> Self {
        Self {
            style: Style {
                width: Val::Percent(100.),
                height: Val::Percent(100.),
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                flex_direction: FlexDirection::Column,
                ..default()
            },
            ..default()
        }
    }

    fn with_background(mut self, color: Color) -> Self {
        self.background_color = Some(color);
        self
    }

    fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    fn with_spacing(mut self, spacing: f32) -> Self {
        self.spacing = spacing;
        self
    }

    fn add_button(
        mut self,
        text: impl Into<String>,
        action: GameState,
        enabled: bool,
    ) -> Self {
        self.buttons.push(MenuButton {
            text: text.into(),
            action,
            style: None,
            enabled,
        });
        self
    }

    fn add_styled_button(
        mut self,
        text: impl Into<String>,
        action: GameState,
        style: Style,
        enabled: bool,
    ) -> Self {
        self.buttons.push(MenuButton {
            text: text.into(),
            action,
            style: Some(style),
            enabled,
        });
        self
    }

    fn build(self, commands: &mut Commands) -> Entity {
        let root = commands
            .spawn(NodeBundle {
                style: self.style,
                background_color: self.background_color.map(|c| c.into()).unwrap_or_default(),
                ..default()
            })
            .with_children(|parent| {
                if let Some(title) = self.title {
                    parent.spawn(TextBundle::from_section(
                        title,
                        TextStyle {
                            font_size: 48.,
                            color: Color::WHITE,
                            ..default()
                        }
                    ));

                    parent.spawn(NodeBundle {
                        style: Style {
                            height: Val::Px(self.spacing),
                            ..default()
                        },
                        ..default()
                    });
                }

                for button in self.buttons {
                    let button_style = button.style.unwrap_or(Style {
                        width: Val::Px(200.),
                        height: Val::Px(50.),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        margin: UiRect::all(Val::Px(5.)),
                        ..default()
                    });
                    parent.spawn(ButtonBundle {
                        style: button_style,
                        background_color: if button.enabled {
                            Color::srgb(0.25, 0.25, 0.25).into()
                        } else {
                            Color::srgb(0.5, 0.5, 0.5).into()
                        },
                        ..default()
                    })
                    .with_children(|parent| {
                        parent.spawn(TextBundle::from_section(
                            button.text,
                            TextStyle {
                                font_size: 24.,
                                color: if button.enabled {
                                    Color::WHITE
                                } else {
                                    Color::srgb(0.5, 0.5, 0.5)
                                },
                                ..default()
                            }
                        ));
                    })
                    .insert(ButtonAction(button.action));
                }
            })
            .id();
        root
    }
}

And here’s the update system that handles all the interactive bits:

fn update_menu(
    mut interaction_query: Query<
        (&ButtonAction, &Interaction, &mut BackgroundColor),
        (Changed<Interaction>, With<Button>),
    >,
    mut game_state: ResMut<NextState<GameState>>,
) {
    for (action, interaction, mut color) in &mut interaction_query {
        match *interaction {
            Interaction::Pressed => {
                *color = menu::PRESSED.into();
                game_state.set(action.0.clone());
            },
            Interaction::Hovered => *color = menu::HOVERED.into(),
            Interaction::None => *color = menu::NORMAL.into(),
        }
    }
}

Creating a new menu is easy now. Below an example of how I create the main menu:

fn spawn_main_menu(mut commands: Commands) {
    let entity = MenuBuilder::new()
        .with_title("Pong!")
        .with_spacing(20.)
        .with_background(Color::srgb(0., 0., 0.,))
        .add_button("Play", GameState::Playing, true)
        .add_button("Settings", GameState::Settings, true)
        .add_button("Exit", GameState::Exit, true)
        .build(&mut commands);
    commands.insert_resource(RootEntity(entity));
}

I’m pretty sure there are better ways to organize this code, but honestly? I’m quite happy with how it turned out! Getting everything to work “the Rust way” was already quite a challenge, let alone making it clean and maintainable.

Where I Got Stuck

The need to develop everything from scratch however, is exactly where things got tricky. While creating buttons and handling state changes was straightforward enough, I hit a wall when trying to build:

  1. A custom AI difficulty selector
  2. Keyboard layout configuration options

Since Bevy doesn’t provide these controls out of the box, I have to roll up my sleeves and create them myself.

What’s Next?

For next week, I’m planning to experiment with other GUI frameworks for Bevy, particularly the bevy_egui package. I’m curious to see if it might offer some better solutions for what I’m trying to build. I’ll make sure to report back on how that goes!

Check It Out!

If you’re interested in seeing how this Bevy menu system works in practice, the source code for my Pong! clone is available on GitHub. I’d love to hear your thoughts or suggestions for improvements!

Home » Pong! update: Creating My Own Bevy Menu System

Comments

2 responses to “Pong! update: Creating My Own Bevy Menu System”

  1. […] been two weeks since my last update, and boy, has it been an interesting journey. In my last update, I created a basic menu in my Pong! […]

  2. […] leveraged my existing EGUI-based menu framework to build the player selection screen. The builder pattern I developed in November makes creating new menus quick and […]

Leave a Reply

Your email address will not be published. Required fields are marked *