Building a Space Sim in Bevy - Part 5: Basic HUD
Creating a minimal heads-up display showing faction credits and coordinates using Bevy's UI system.
Building a Space Sim in Bevy - Part 5: Basic HUD
We’ve got ships, stations, and a beautiful starfield. But how do we know what’s actually happening? Where are we? How much money does our faction have?
Time for a HUD. We’ll keep it minimal - just coordinates and credits. Enough to know what’s going on without cluttering the view.
Bevy’s UI System
If you’ve worked with web CSS, Bevy’s UI will feel familiar. It uses flexbox-style layout with nodes, containers, and text. Everything is a component, just like the rest of Bevy.
The key concepts:
- Node: A UI container, like an HTML
<div>. Has size, padding, positioning. - Text: Text content with font settings.
- BackgroundColor: Fill color for UI elements.
- Flexbox properties:
justify_content,align_items,flex_directionfor layout.
Let me show you how it works in practice.
HUD Components
First, let’s define markers for our UI elements. This lets us find and update them later:
// src/ui/components.rs
use bevy::prelude::*;
/// Marker for the root HUD container
#[derive(Component)]
pub struct HudRoot;
/// Marker for the coordinates display
#[derive(Component)]
pub struct CoordsText;
/// Marker for the credits display
#[derive(Component)]
pub struct CreditsText; Simple marker components. We’ll query for these to update the text each frame.
Designing the Layout
I want a clean top bar with:
- Credits on the left (your faction’s wallet)
- Coordinates on the right (where you’re looking)
Here’s how I think about UI in Bevy:
HudRoot (fills screen, flex column, space-between)
└── TopBar (full width, flex row, space-between)
├── CreditsBox (semi-transparent background)
│ └── CreditsText
└── CoordsBox (semi-transparent background)
└── CoordsText Each layer is a container with flexbox properties controlling layout.
Building the HUD
Let’s define some colors first:
// src/ui/systems.rs
mod colors {
use bevy::prelude::*;
pub const BACKGROUND: Color = Color::srgba(0.0, 0.0, 0.0, 0.7);
pub const TEXT: Color = Color::srgba(0.9, 0.9, 0.9, 1.0);
pub const ACCENT: Color = Color::srgba(0.4, 0.8, 1.0, 1.0); // Cyan for emphasis
} Now the spawn system:
pub fn spawn_hud(
mut commands: Commands,
existing_hud: Query<Entity, With<HudRoot>>,
) {
// Idempotent - don't create multiple HUDs
if !existing_hud.is_empty() {
return;
}
commands.spawn((
HudRoot,
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::all(Val::Px(20.0)),
..default()
},
)).with_children(|parent| {
// Top bar
parent.spawn(Node {
width: Val::Percent(100.0),
justify_content: JustifyContent::SpaceBetween,
..default()
}).with_children(|top_bar| {
// Left side: Credits
top_bar.spawn((
Node {
padding: UiRect::axes(Val::Px(15.0), Val::Px(8.0)),
..default()
},
BackgroundColor(colors::BACKGROUND),
)).with_children(|box_| {
box_.spawn((
CreditsText,
Text::new("Credits: 0"),
TextFont { font_size: 18.0, ..default() },
TextColor(colors::ACCENT),
));
});
// Right side: Coordinates
top_bar.spawn((
Node {
padding: UiRect::axes(Val::Px(15.0), Val::Px(8.0)),
..default()
},
BackgroundColor(colors::BACKGROUND),
)).with_children(|box_| {
box_.spawn((
CoordsText,
Text::new("X: 0 Y: 0"),
TextFont { font_size: 18.0, ..default() },
TextColor(colors::TEXT),
));
});
});
});
info!("HUD spawned");
} Let me walk through what’s happening:
- HudRoot fills the entire screen (
100%width and height) - flex_direction: Column means children stack vertically
- justify_content: SpaceBetween pushes children to edges (top bar at top, space below)
- padding gives us margins from the screen edge
- The TopBar uses
JustifyContent::SpaceBetweento push credits left and coords right - Each text box has a semi-transparent background for readability
Updating Coordinates
The coordinates should show different things depending on context:
- In FreeCam mode: Show camera position
- In Piloting mode: Show the ship’s position
pub fn update_coords_display(
mode: Res<ControlMode>,
camera_query: Query<&Transform, With<MainCamera>>,
ship_query: Query<&Transform, With<Ship>>,
mut text_query: Query<&mut Text, With<CoordsText>>,
) {
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
// Get position based on what we're controlling
let pos = match mode.controlled_entity() {
Some(entity) => {
// Piloting a ship - show ship position
ship_query.get(entity).map(|t| t.translation).ok()
}
None => {
// FreeCam or Following - show camera position
camera_query.get_single().map(|t| t.translation).ok()
}
};
if let Some(pos) = pos {
**text = format!("X: {:.0} Y: {:.0}", pos.x, pos.y);
}
} The mode.controlled_entity() returns Some(entity) when you’re piloting a ship, and None for free camera or follow modes. This makes the HUD context-aware.
Updating Credits
Credits come from the faction system. In our game, factions have shared wallets - not individual players:
pub fn update_credits_display(
faction_registry: Res<FactionRegistry>,
mut text_query: Query<&mut Text, With<CreditsText>>,
) {
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
// Show the player faction's credits
let credits = faction_registry.credits(&FactionId::player());
**text = format!("Credits: {}", credits);
} When NPC traders make profitable trades, they add money to their faction’s wallet. The player can watch their faction’s wealth grow (or shrink) in real-time.
The Plugin
Let’s wire everything up:
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum UiSet {
Update,
}
pub struct UiPlugin;
impl Plugin for UiPlugin {
fn build(&self, app: &mut App) {
app.configure_sets(
Update,
UiSet::Update.after(StationSet::Visual)
)
.add_systems(OnEnter(GameState::Playing), spawn_hud)
.add_systems(Update, (
update_coords_display,
update_credits_display,
).in_set(UiSet::Update)
.run_if(in_state(GameState::Playing)));
}
} The UI updates after station visuals are done. Not strictly necessary for these simple displays, but good practice for more complex UI.
Testing It Out
Run the game! You should see:
- Top-left: “Credits: X” in cyan
- Top-right: “X: 0 Y: 0” in white (or wherever your camera is)
Fly around and watch the coordinates change. The numbers should track smoothly with your movement.
Why Keep It Minimal?
You might wonder why I’m not adding more UI elements. Speed indicator? Cargo display? Station info panels?
The reason is simple: we don’t have those features yet. The HUD should reflect what the game actually supports. As we add features, we’ll add corresponding UI.
This approach prevents two problems:
- UI elements showing “N/A” or dummy data (confusing for players)
- Building UI before the underlying systems exist (wasted effort)
We’ll expand the HUD as we build more features. In Part 13, we add a minimap!
Bevy UI Tips
A few things I learned the hard way:
Use marker components: Don’t try to store entity IDs. Instead, mark UI elements with components and query for them.
Flexbox is your friend: Most layouts are achievable with flex containers. Avoid absolute positioning unless necessary.
Text updates are cheap: Changing text content every frame is fine. Bevy handles it efficiently.
Semi-transparent backgrounds: UI over game content needs contrast. A 70% black background (srgba(0,0,0,0.7)) works well.
What We Learned
- Bevy UI system: Nodes, Text, flexbox layout
- Marker components: Finding specific UI elements via queries
- BackgroundColor: Styling containers
- Context-aware UI: Display changes based on control mode
- Faction integration: Credits come from shared faction wallet
What’s Next
We can see where we are and how much money our faction has. But the economy doesn’t actually do anything yet!
In Part 6, we’ll build the economic foundation - commodities, station markets, and the pricing system that makes trade profitable. Then in Part 7, we’ll add NPC traders who actually use this economy.
The galaxy is about to get a lot more interesting.