Documentation

Learn Simulant

Everything you need to know to build games with Simulant

UI System Overview

Simulant includes a comprehensive UI system for creating in-game interfaces, menus, HUDs, and interactive elements. The UI system uses a CSS-like box model and provides a range of built-in widgets.

Architecture

The UI system is built on top of the StageNode hierarchy:

Scene
└── UIManager (manages UI input routing)
    └── Widget (base class for all UI elements)
        ├── Label
        ├── Button
        ├── Image
        ├── ProgressBar
        ├── TextEntry
        ├── Frame (layout container)
        └── Keyboard (on-screen keyboard)

Quick Start

Creating a UI

  1. Create a UIManager
  2. Create widgets through the UI manager
  3. Position and configure widgets
  4. Render with a 2D camera
class MenuScene : public Scene<MenuScene> {
private:
    CameraID ui_camera_;
    UIManager* ui_manager_;
    
    void on_load() override {
        // Create 2D camera for UI
        ui_camera_ = create_child<Camera2D>()->id();
        
        // Get UI manager
        ui_manager_ = ui;  // 'ui' is a property on Scene
        
        // Create UI elements
        create_menu();
        
        // Create render pipeline
        auto layer = compositor->create_layer(this, camera(ui_camera_));
        layer->set_priority(RENDER_PRIORITY_FOREGROUND);
        link_pipeline(layer);
    }
    
    void create_menu() {
        auto title = ui_manager_->new_widget_as_label("My Game", 100, 50);
        title->set_font_size(32);
        title->set_text_align(TEXT_ALIGN_CENTER);
        
        auto play_button = ui_manager_->new_widget_as_button("Play", 100, 100);
        play_button->on_click.connect([this]() {
            scenes->load_and_activate("game");
        });
    }
};

Widgets

Label

Displays text:

auto label = ui->new_widget_as_label("Hello World", x, y);
label->set_text("New Text");
label->set_font_size(24);
label->set_text_color(Colour::WHITE);
label->set_text_align(TEXT_ALIGN_CENTER);

Button

Clickable button with optional text:

auto button = ui->new_widget_as_button("Click Me", x, y);
button->set_text("New Label");

// Handle clicks
button->signal_activated().connect([]() {
    S_INFO("Button clicked!");
});

// Visual customization
button->set_background_color(Colour(0.2f, 0.5f, 0.8f));
button->set_border_width(2);
button->set_border_color(Colour::WHITE);

Image

Display a texture:

auto texture = assets->load_texture("ui/icon.png");
auto image = ui->new_widget_as_image(texture, x, y);
image->set_size(64, 64);

// Change source rectangle (for sprite sheets)
image->set_source_rect(Vec2(0, 0), Vec2(32, 32));

ProgressBar

Show progress/values:

auto progress = ui->new_widget_as_progress_bar(x, y, 200, 20);
progress->set_value(0.75f);  // 75% full (0.0 to 1.0)
progress->set_fill_color(Colour::GREEN);
progress->set_background_color(Colour::GRAY);

TextEntry

Text input field:

auto text_entry = ui->new_widget_as_text_entry(x, y, 200, 30);
text_entry->set_text("Enter name...");
text_entry->set_max_length(20);

text_entry->signal_done().connect([this]() {
    S_INFO("User entered: {}", text_entry->text());
});

Widget Sizing

Widgets support multiple resize modes:

Resize Modes

// Fixed size (doesn't change)
widget->set_resize_mode(RESIZE_MODE_FIXED);
widget->set_size(200, 50);

// Fixed width, height adjusts to content
widget->set_resize_mode(RESIZE_MODE_FIXED_WIDTH);
widget->set_size(200, 0);  // Height will adjust

// Fixed height, width adjusts
widget->set_resize_mode(RESIZE_MODE_FIXED_HEIGHT);
widget->set_size(0, 50);

// Both dimensions adjust to content
widget->set_resize_mode(RESIZE_MODE_FIT_CONTENT);
widget->set_size(0, 0);

Box Model

Widgets use a CSS-like box model:

┌─────────────────────────┐
│        Border           │
│  ┌───────────────────┐  │
│  │     Background    │  │
│  │  ┌─────────────┐  │  │
│  │  │ Foreground  │  │  │
│  │  │  ┌───────┐  │  │  │
│  │  │  │ Text  │  │  │  │
│  │  │  └───────┘  │  │  │
│  │  └─────────────┘  │  │
│  └───────────────────┘  │
└─────────────────────────┘

Configure the box model:

widget->set_border_width(2);
widget->set_padding(10);              // Internal spacing
widget->set_background_color(Colour::BLUE);
widget->set_border_color(Colour::WHITE);

Layout with Frames

Frames organize widgets in rows or columns:

Creating a Frame

auto frame = ui->new_widget_as_frame("Menu", x, y, 300, 400);
frame->set_layout_direction(LAYOUT_DIRECTION_VERTICAL);
frame->set_padding(10);
frame->set_spacing(5);  // Space between children

Adding Widgets to Frames

// Widgets become children of the frame
auto button1 = frame->create_child<Button>("Button 1", 0, 0);
button1->set_resize_mode(RESIZE_MODE_FIXED_WIDTH);
button1->set_width(280);

auto button2 = frame->create_child<Button>("Button 2", 0, 0);
button2->set_resize_mode(RESIZE_MODE_FIXED_WIDTH);
button2->set_width(280);

Frame Options

frame->set_layout_direction(LAYOUT_DIRECTION_HORIZONTAL);
frame->set_layout_direction(LAYOUT_DIRECTION_VERTICAL);  // Default

frame->set_spacing(10);        // Gap between children
frame->set_padding(5);         // Internal padding
frame->set_pack_start(true);   // Pack from start (top/left)

Input Handling

UI widgets handle input automatically through the UIManager:

Mouse Input

button->signal_pointer_down().connect([]() {
    S_INFO("Mouse down on button");
});

button->signal_pointer_up().connect([]() {
    S_INFO("Mouse up on button");
});

button->signal_activated().connect([]() {
    S_INFO("Button clicked");
});

Multi-Touch

The UI system supports multi-touch:

// Each touch has a unique ID
widget->signal_pointer_down().connect([](int touch_id, Vec2 pos) {
    S_INFO("Touch {} at {}", touch_id, pos);
});

Keyboard Navigation

Widgets support focus chain navigation:

widget->set_focusable(true);
widget->focus();

// Navigate focus
ui->focus_next();
ui->focus_previous();

// Get focused widget
Widget* focused = ui->focused_widget();

Styling

UIConfig

Create reusable themes:

UIConfig config;
config.normal_color = Colour(0.2f, 0.2f, 0.2f);
config.hover_color = Colour(0.3f, 0.3f, 0.3f);
config.active_color = Colour(0.4f, 0.4f, 0.4f);
config.font_size = 16;

ui_manager_->set_config(config);

Widget Layers

Widgets have multiple visual layers:

// Border layer
widget->set_border_width(2);
widget->set_border_color(Colour::WHITE);

// Background layer
widget->set_background_color(Colour::BLUE);

// Foreground layer (varies by widget type)
widget->set_foreground_visible(true);
widget->set_foreground_color(Colour::GREEN);

// Text layer
widget->set_text("Hello");
widget->set_text_color(Colour::WHITE);
widget->set_font_size(24);

Progress Bar Foreground

For progress bars, the foreground is the fill indicator:

progress->set_foreground_visible(true);
progress->set_foreground_color(Colour::GREEN);
// Fill amount controlled by set_value()

Button Foreground

For buttons, foreground can be an icon:

button->set_foreground_visible(true);
button->set_foreground_texture(icon_texture);

Text Entry & Keyboard

Text Entry Widget

auto text_entry = ui->new_widget_as_text_entry(x, y, 200, 30);
text_entry->set_text("Default");
text_entry->set_placeholder("Enter text...");

text_entry->signal_text_changed().connect([](const std::string& text) {
    S_INFO("Text changed to: {}", text);
});

text_entry->signal_done().connect([]() {
    S_INFO("Finished editing");
});

On-Screen Keyboard

For touch devices or games without physical keyboards:

auto keyboard = ui->new_widget_as_keyboard(x, y);
keyboard->set_target(text_entry);  // Auto-update text entry

// Or handle manually
keyboard->signal_activated().connect([](char c) {
    S_INFO("Key pressed: {}", c);
});

keyboard->signal_done().connect([]() {
    S_INFO("Keyboard dismissed");
});

Note: The Keyboard widget is in alpha state. See the Widgets Reference for known limitations.

HUD Example

Complete HUD implementation:

class HUD : public StageNode {
private:
    Label* score_label_;
    Label* lives_label_;
    ProgressBar* health_bar_;
    
public:
    void on_load() override {
        ui_manager_ = stage_->ui;
        
        // Score
        score_label_ = ui_manager_->new_widget_as_label("Score: 0", 10, 10);
        score_label_->set_font_size(24);
        score_label_->set_text_color(Colour::WHITE);
        
        // Lives
        lives_label_ = ui_manager_->new_widget_as_label("Lives: 3", 10, 40);
        lives_label_->set_font_size(24);
        
        // Health bar
        health_bar_ = ui_manager_->new_widget_as_progress_bar(10, 70, 200, 20);
        health_bar_->set_value(1.0f);
        health_bar_->set_fill_color(Colour::GREEN);
    }
    
    void update_score(int score) {
        score_label_->set_text("Score: " + std::to_string(score));
    }
    
    void update_lives(int lives) {
        lives_label_->set_text("Lives: " + std::to_string(lives));
    }
    
    void update_health(float health) {
        health_bar_->set_value(health);
        
        // Change color based on health
        if (health > 0.6f) {
            health_bar_->set_fill_color(Colour::GREEN);
        } else if (health > 0.3f) {
            health_bar_->set_fill_color(Colour::YELLOW);
        } else {
            health_bar_->set_fill_color(Colour::RED);
        }
    }
};

Menu Example

Complete main menu:

class MenuScene : public Scene<MenuScene> {
private:
    CameraID camera_;
    
    void on_load() override {
        // 2D camera
        camera_ = create_child<Camera2D>()->id();
        
        // Title
        auto title = ui->new_widget_as_label("My Game", 400, 100);
        title->set_font_size(48);
        title->set_text_align(TEXT_ALIGN_CENTER);
        
        // Menu frame
        auto menu_frame = ui->new_widget_as_frame("", 350, 250, 300, 200);
        menu_frame->set_layout_direction(LAYOUT_DIRECTION_VERTICAL);
        menu_frame->set_spacing(10);
        menu_frame->set_padding(10);
        
        // Buttons
        auto play_btn = menu_frame->create_child<Button>("Play", 0, 0);
        play_btn->set_resize_mode(RESIZE_MODE_FIXED_WIDTH);
        play_btn->set_width(280);
        play_btn->signal_activated().connect([this]() {
            scenes->load_and_activate("game", SCENE_CHANGE_BEHAVIOUR_UNLOAD);
        });
        
        auto settings_btn = menu_frame->create_child<Button>("Settings", 0, 0);
        settings_btn->set_resize_mode(RESIZE_MODE_FIXED_WIDTH);
        settings_btn->set_width(280);
        settings_btn->signal_activated().connect([this]() {
            scenes->load_and_activate("settings");
        });
        
        auto quit_btn = menu_frame->create_child<Button>("Quit", 0, 0);
        quit_btn->set_resize_mode(RESIZE_MODE_FIXED_WIDTH);
        quit_btn->set_width(280);
        quit_btn->signal_activated().connect([this]() {
            app->shutdown();
        });
        
        // Render pipeline
        auto layer = compositor->create_layer(this, camera(camera_));
        layer->set_priority(RENDER_PRIORITY_FOREGROUND);
        link_pipeline(layer);
    }
};

Best Practices

1. Use a Separate Camera for UI

// Good: Separate 2D camera for UI
auto ui_camera = create_child<Camera2D>();
auto ui_layer = compositor->create_layer(this, ui_camera);
ui_layer->set_priority(RENDER_PRIORITY_FOREGROUND);

// Bad: Rendering UI in 3D space
// auto layer = compositor->create_layer(this, perspective_camera);

2. Use Frames for Layout

// Good: Using frame for automatic layout
auto frame = ui->new_widget_as_frame("", x, y, w, h);
frame->set_layout_direction(LAYOUT_DIRECTION_VERTICAL);

// Bad: Manual positioning
// button1->move_to(100, 100);
// button2->move_to(100, 150);

3. Use Resize Modes Appropriately

// Good: Let text adjust size
label->set_resize_mode(RESIZE_MODE_FIT_CONTENT);

// Good: Fixed size for buttons
button->set_resize_mode(RESIZE_MODE_FIXED);
button->set_size(200, 50);

4. Handle Widget Destruction

Widgets are destroyed with their parent StageNode:

widget->destroy();  // Removes from UI
// or
parent->destroy();  // Removes widget and all children

5. Use Localization

Make UI translatable:

auto label = ui->new_widget_as_label(_T("Play Game"), x, y);
auto button = ui->new_widget_as_button(_T("Settings"), x, y);

See Localization for details.

Performance Tips

1. Minimize Widget Updates

Only update when values change:

// Bad: Update every frame
void on_update(float dt) override {
    score_label_->set_text("Score: " + std::to_string(score));
}

// Good: Update only when score changes
void on_score_changed(int new_score) {
    score_label_->set_text("Score: " + std::to_string(new_score));
}

2. Use 2D Camera for UI

3D cameras add overhead for UI rendering.

3. Limit Active Widgets

Too many active widgets impact performance. Hide off-screen widgets:

if (!widget->is_on_screen()) {
    widget->set_visible(false);
}

Troubleshooting

Widgets Not Showing

  • Check UI camera is set up correctly
  • Verify layer priority puts UI in foreground
  • Ensure widget positions are within viewport

Input Not Working

  • Check UIManager exists in scene
  • Verify widgets have correct size for hit testing
  • Ensure widget is visible and enabled

Text Not Displaying

  • Check font is loaded
  • Verify text color is visible against background
  • Ensure widget size fits text content

Next Steps

See Also