UI Systems Guide¶
This guide explains the three main UI systems in Brkrs and how they interact.
Overview¶
The UI is split into three independent but coordinated systems:
Score Display — Shows cumulative score in the top-right corner.
Lives Counter — Tracks remaining lives below the score display.
Designer Palette — Allows designers to select and place bricks on the grid during gameplay (developer feature).
Score Display¶
Module: src/ui/score_display.rs
Purpose: Display the player’s cumulative score as a HUD element.
How it works:
spawn_score_display_system()runs every Update and creates the display entity once (idempotent) when:No
ScoreDisplayUientity exists, andThe
UiFontsresource is available (desktop loads at Startup; WASM provides it once assets are ready).
update_score_display_system()updates the text wheneverScoreStatechanges, using Bevy’s change detection to avoid unnecessary updates.
Spawn location: Top-right corner (Node with right: Val::Px(12.0), top: Val::Px(40.0)), positioned below the lives counter to avoid overlap.
Score mechanics:
Points awarded based on brick type (see
docs/bricks.md)Score persists across level transitions
Score resets to 0 when a New Game is started from the Game Over screen
Every 5000 points triggers a milestone bonus (extra life)
Special cases: Question brick (53) awards random 25-300 points; Extra Ball (41) and Magnet bricks (55-56) award 0 points
Dependency: Requires UiFonts resource and ScoreState resource.
If missing, the system logs a warning and defers spawning until fonts become available.
Lives Counter¶
Module: src/ui/lives_counter.rs
Purpose: Display the player’s remaining lives as a small HUD element.
How it works:
spawn_lives_counter()runs every Update and creates the counter entity once (idempotent) when:No
LivesCounterentity exists, andThe
UiFontsresource is available (desktop loads at Startup; WASM provides it once assets are ready).
update_lives_counter()updates the text wheneverLivesStatechanges and is scheduled afterRespawnSystems::Scheduleto reflect the latest respawn logic.
Spawn location: Top-right corner (Node with right: Val::Px(12.0), top: Val::Px(12.0)).
Dependency: Requires UiFonts resource.
If missing (WASM startup), the system logs a warning and defers spawning until fonts become available.
Legacy Overlay Removal Note¶
Legacy gameplay game-over overlay behavior has been retired for this project state.
GameOverRequestedremains a buffered message in respawn/life-loss flow.No gameplay UI system now spawns a legacy
GameOverOverlayentity.Pause and cheat systems no longer depend on legacy overlay marker presence.
Designer Palette¶
Module: src/ui/palette.rs
Purpose: Provide an in-game tool for designers to select and place bricks on the grid. This is a developer feature and can be disabled or hidden in production builds.
User flow:
Press P to toggle the palette open/closed.
When open, the palette displays a list of available brick types (e.g., Simple Brick type 20, Indestructible type 90) with small color previews.
Click a preview to select that brick type.
A “ghost” preview follows the cursor over the grid when a type is selected.
Hold the left mouse button and drag over grid cells to place bricks at those locations.
Systems:
toggle_palette()— Listens for P keypress and togglesPaletteState::open.ensure_palette_ui()— Spawns/despawns the UI panel whenPaletteStatechanges. Previews show material colors resolved fromTypeVariantRegistrywhen available.handle_palette_selection()— UpdatesSelectedBrick::type_idwhen a preview button is clicked.update_palette_selection_feedback()— Highlights the selected preview with a bright yellow background.update_ghost_preview()— Spawns/positions a semi-transparent preview cube that follows the cursor over valid grid cells.place_bricks_on_drag()— Spawns actual brick entities on the grid when the mouse is held and dragged. Prevents duplicate placement at the same cell.
Grid integration: Uses camera raycasting to convert cursor positions to world coordinates and then to grid indices (0..GRID_HEIGHT × 0..GRID_WIDTH).
Material integration: When TypeVariantRegistry is available (loaded by TextureManifestPlugin), previews show the actual brick material colors.
Falls back to gray if unavailable.
Resource Dependencies¶
All UI systems depend on platform-specific font availability:
Desktop:
UiFontsis inserted at Startup byload_ui_fonts()inFontsPlugin, so all systems can spawn immediately.WASM:
UiFontsis inserted asynchronously in Update byensure_ui_fonts_loaded()inFontsPlugin. UI systems check for the resource and log warnings if it’s missing during early frames; they will successfully spawn once fonts are ready.
Scheduling Summary¶
All UI systems run in the Update schedule:
System |
Condition |
Order |
|---|---|---|
|
Every frame, idempotent |
Before |
|
Only if |
After |
|
Every frame |
Early (no explicit ordering) |
|
Only if |
After |
|
Only if button interaction changed |
During standard interaction phase |
|
Every frame (previews may spawn dynamically) |
After |
|
Every frame |
Late (cursor tracking) |
|
Every frame if mouse held |
Late (after ghost preview) |
Adding New UI Systems¶
When adding a new UI system:
Create a module under
src/ui/(e.g.,new_feature.rs).Add module docs explaining:
Purpose of the UI.
When/how it spawns (idempotent or event-driven?).
Dependency on
UiFontsor other resources.Scheduling relative to other systems.
Use
Option<Res<UiFonts>>in function signatures to gracefully handle missing fonts on WASM.Register systems in
src/lib.rsunderpub fn run(), respecting scheduling dependencies.Add unit tests in the same module if applicable (see
tests/directory).
Common Patterns¶
Idempotent Spawn¶
Query for an existing entity before spawning:
let existing: Query<Entity, With<MyUiMarker>> = ...;
if !existing.is_empty() {
return; // Already spawned
}
Handle Missing Fonts¶
Use Option<Res<UiFonts>> and provide a fallback:
let font = ui_fonts
.as_ref()
.map(|f| f.orbitron.clone())
.unwrap_or_default();
Cursor-to-Grid Conversion¶
See palette.rs::cursor_to_grid() for the full implementation using camera raycasting.
Testing UI Systems¶
Unit tests for UI systems are in tests/ and focus on:
Spawning behavior (idempotent, resource-dependent).
State transitions and event handling.
Material/preview resolution when registries are available.
UI update systems MUST be reactive where possible:
Prefer queries filtered with
Changed<T>so updates only run when source data changes.Avoid per-frame UI updates that re-write unchanged text/material state.
Example:
cargo test --lib ui::lives_counter
See tests/ for full test suites and helper utilities.
Query Failure Policies (Constitution VIII: Error Recovery Patterns)¶
All UI systems return Result<(), UiSystemError> and use the following patterns for expected query failures:
Pattern: Required Single Entity¶
When a single UI entity MUST exist:
pub fn my_ui_system(
mut query: Query<&mut Transform, With<MyUiMarker>>,
) -> Result<(), UiSystemError> {
let mut transform = query
.get_single_mut()
.map_err(|_| UiSystemError::EntityNotFound("MyUiMarker entity".to_string()))?;
// ... use transform
Ok(())
}
Behavior: If the entity is not found, the system returns an error and skips the update without panicking. Callers decide whether to log a warning or silently continue.
Pattern: Optional Single Entity¶
When a single UI entity MAY exist:
pub fn my_optional_ui_system(
query: Query<&Text, With<ScoreDisplayUi>>,
) -> Result<(), UiSystemError> {
let Some(text) = query.get_single().ok() else {
return Ok(()); // Entity doesn't exist yet; that's ok.
};
// ... use text
Ok(())
}
Behavior: If the entity is not found, the system returns Ok(()) and continues.
This is safe for spawning systems that may run before entities are created.
Pattern: Required Resource¶
When a resource MUST be available:
pub fn my_system(
fonts: Res<UiFonts>,
) -> Result<(), UiSystemError> {
if !fonts.is_loaded() {
return Err(UiSystemError::AssetNotAvailable("UiFonts not yet loaded".to_string()));
}
// ... use fonts
Ok(())
}
Behavior: If the resource is not available or not yet loaded (WASM), the system returns an error. The caller (app.rs) logs a diagnostic and reschedules the system to try again next frame.
Multiple Entities Matching a Query¶
If a query is expected to match 0 or 1 entities but sometimes matches multiple (e.g., due to a bug or race condition):
Use
let Ok(entity) = query.get_single() else { return Ok(()); }to safely skip the update.Optionally log a diagnostic:
warn!("Expected 0-1 entities, found multiple").Return
Ok(())so the game doesn’t crash.
Do not use .unwrap() or .expect() in hot UI paths.
Constitution Compliance Cheatsheet (Bevy 0.17)¶
The UI follows Brkrs Constitution Section VIII and Bevy 0.17’s mandates:
Fallible Systems: UI systems return
Result<(), UiSystemError>and avoid panics. Prefer early returns on missing entities/resources.Reactive Change Detection: Use
Changed<T>to avoid per-frame work for unchanged data. Example: update text only whenLivesStateorCurrentLevelchanges.
pub fn sync_with_current_level(
current: Option<Res<CurrentLevel>>, // Optional during early startup
mut query: Query<&mut Text, With<LevelLabelText>>, // Specific query
) -> Result<(), UiSystemError> {
let Some(current) = current.filter(|c| c.is_changed()) else { return Ok(()); };
let Some(mut text) = query.iter_mut().next() else { return Ok(()); };
**text = format!("Level {}", current.0.number).into();
Ok(())
}
Query Specificity: Use
With<T>/Without<T>to target only relevant entities and maximize parallelism.Safe Queries: Prefer
iter().next()(orget_single().ok()) patterns over.unwrap()/.expect()in hot paths.Scheduling Conflict Avoidance: When multiple queries access the same component mutably, merge them via
ParamSetto avoid B0001.
pub fn update_palette_selection_feedback(
selected: Res<SelectedBrick>,
mut param_set: ParamSet<(
Query<(&PalettePreview, &mut BackgroundColor)>,
Query<(&PalettePreview, &mut BackgroundColor), Added<PalettePreview>>,
)>,
materials_res: Option<Res<Assets<StandardMaterial>>>,
) -> Result<(), UiSystemError> {
let selection_changed = selected.is_changed();
let has_new_previews = !param_set.p1().is_empty();
if !selection_changed && !has_new_previews { return Ok(()); }
// ... update existing via p0(); init new via p1()
Ok(())
}
Messages vs Events: Use observers for event-driven updates (e.g.,
LevelStarted). Use the project’sMessages<T>resource for queued requests (e.g.,GameOverRequested) and keep these patterns distinct.
// Observer: reacts to a raised event
pub fn on_level_started(level: Trigger<On<LevelStarted>>, mut query: Query<&mut Text, With<LevelLabelText>>) -> Result<(), UiSystemError> {
let Some(mut text) = query.iter_mut().next() else { return Ok(()); };
**text = format!("Level {}", level.level_index).into();
Ok(())
}
// Message: enqueue a request handled by a system later
fn request_game_over(world: &mut World) {
world.resource_mut::<Messages<GameOverRequested>>().write(GameOverRequested { remaining_lives: 0 });
}
Asset Handle Reuse: Load assets once and store handles in resources (e.g.,
UiFonts). Do not callasset_server.load()inside spawn/update loops; reuse the cached handles instead.WASM Parity: Fonts may appear later on web; use
Option<Res<UiFonts>>and defer spawning gracefully. For WASM builds, configuregetrandomfor web (RUSTFLAGS='--cfg getrandom_backend="wasm_js"').
These patterns are enforced in tests under tests/ and verified by the US3 behavior preservation suite.