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:

  1. Score Display — Shows cumulative score in the top-right corner.

  2. Lives Counter — Tracks remaining lives below the score display.

  3. 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 ScoreDisplayUi entity exists, and

    • The UiFonts resource is available (desktop loads at Startup; WASM provides it once assets are ready).

  • update_score_display_system() updates the text whenever ScoreState changes, 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 LivesCounter entity exists, and

    • The UiFonts resource is available (desktop loads at Startup; WASM provides it once assets are ready).

  • update_lives_counter() updates the text whenever LivesState changes and is scheduled after RespawnSystems::Schedule to 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.

  • GameOverRequested remains a buffered message in respawn/life-loss flow.

  • No gameplay UI system now spawns a legacy GameOverOverlay entity.

  • 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:

  1. Press P to toggle the palette open/closed.

  2. When open, the palette displays a list of available brick types (e.g., Simple Brick type 20, Indestructible type 90) with small color previews.

  3. Click a preview to select that brick type.

  4. A “ghost” preview follows the cursor over the grid when a type is selected.

  5. Hold the left mouse button and drag over grid cells to place bricks at those locations.

Systems:

  • toggle_palette() — Listens for P keypress and toggles PaletteState::open.

  • ensure_palette_ui() — Spawns/despawns the UI panel when PaletteState changes. Previews show material colors resolved from TypeVariantRegistry when available.

  • handle_palette_selection() — Updates SelectedBrick::type_id when 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: UiFonts is inserted at Startup by load_ui_fonts() in FontsPlugin, so all systems can spawn immediately.

  • WASM: UiFonts is inserted asynchronously in Update by ensure_ui_fonts_loaded() in FontsPlugin. 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

spawn_lives_counter

Every frame, idempotent

Before update_lives_counter

update_lives_counter

Only if LivesState changed

After RespawnSystems::Schedule

toggle_palette

Every frame

Early (no explicit ordering)

ensure_palette_ui

Only if PaletteState changed

After toggle_palette

handle_palette_selection

Only if button interaction changed

During standard interaction phase

update_palette_selection_feedback

Every frame (previews may spawn dynamically)

After handle_palette_selection

update_ghost_preview

Every frame

Late (cursor tracking)

place_bricks_on_drag

Every frame if mouse held

Late (after ghost preview)

Adding New UI Systems

When adding a new UI system:

  1. Create a module under src/ui/ (e.g., new_feature.rs).

  2. Add module docs explaining:

    • Purpose of the UI.

    • When/how it spawns (idempotent or event-driven?).

    • Dependency on UiFonts or other resources.

    • Scheduling relative to other systems.

  3. Use Option<Res<UiFonts>> in function signatures to gracefully handle missing fonts on WASM.

  4. Register systems in src/lib.rs under pub fn run(), respecting scheduling dependencies.

  5. 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):

  1. Use let Ok(entity) = query.get_single() else { return Ok(()); } to safely skip the update.

  2. Optionally log a diagnostic: warn!("Expected 0-1 entities, found multiple").

  3. 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 when LivesState or CurrentLevel changes.

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() (or get_single().ok()) patterns over .unwrap()/.expect() in hot paths.

  • Scheduling Conflict Avoidance: When multiple queries access the same component mutably, merge them via ParamSet to 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’s Messages<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 call asset_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, configure getrandom for web (RUSTFLAGS='--cfg getrandom_backend="wasm_js"').

These patterns are enforced in tests under tests/ and verified by the US3 behavior preservation suite.