Create a HUD with TGUI

Add a score label and health bar to the shared TGUI session established in Create a UIController. The game owns health/score values, the HUD manages their widgets and layout, and the UI controller owns session input, time, and scene publication. Menus and chat can share that same session.

Start with First Game or the Character example, then complete the UIController lesson’s module selection and game integration. This lesson replaces First Game’s direct score display. For RML document authoring, use Create an interactive GUI with RmlUi.

Select TGUI

Complete Select TGUI in the UIController lesson for the module, dependency, and link setup. The shared controller supplies the session and upload path used below.

Give the HUD its own class

Create hud.h:

#pragma once

#include <cheryl/ui/tgui/session.h>

#include <TGUI/Widgets/Label.hpp>
#include <TGUI/Widgets/ProgressBar.hpp>

#include <algorithm>
#include <format>

namespace Tutorial {
    class Hud final {
        CE::UI::TGUI::Session& session_;
        tgui::Label::Ptr score_;
        tgui::ProgressBar::Ptr health_;

    public:
        explicit Hud(CE::UI::TGUI::Session& session)
        : session_(session) {
            create_widgets();
        }

        void update(int score, unsigned int health) {
            health = std::min(health, 100u);
            score_->setText(std::format("Score: {}", score));
            health_->setValue(health);
            health_->setText(std::format("Health: {}/100", health));
        }

        // Remove this component during play, on the session's UI owner.
        // Release the Hud afterwards; the shared session remains usable.
        void remove() {
            auto& gui = session_.gui();
            static_cast<void>(gui.remove(score_));
            static_cast<void>(gui.remove(health_));
        }

    private:
        void create_widgets() {
            score_ = tgui::Label::create("Score: 0");
            score_->setTextSize(24);
            score_->setSize({220, 32});
            session_.gui().add(score_);
            session_.set_layout(
                score_, {
                            .anchor = {0, 0},
                            .offset = {.fixed = {24, 16}},
                        }
            );

            health_ = tgui::ProgressBar::create();
            health_->setSize({220, 32});
            health_->setMaximum(100);
            session_.gui().add(health_);
            session_.set_layout(
                health_, {
                             .anchor = {1, 0},
                             .offset = {.fixed = {-24, 16}},
                         }
            );
        }
    };
}

Hud borrows a Session&. Construct it on the simulation/UI owner after the session’s initial view is set. Its native widget handles share toolkit ownership with the GUI; the session must remain alive while they are used. The session’s lifetime stays with the UI controller and requires no shared_ptr<Session>.

update(score, health) receives only the presentation values this component needs. It formats the score and bounds the health display to 0..100; game rules decide when those values change. Widgets inherit the session’s configured default font. Configure Engine fonts before creating the session when a different default is wanted.

Add widgets to the GUI before calling set_layout(). The label attaches its top-left corner to the parent’s top-left; the bar attaches its top-right corner to the parent’s top-right. Coordinates are logical UI units with downward-positive Y. The bindings follow the shared view as it changes. See typed layout for proportional sizing and offsets.

Add the HUD to the UI owner

Extend the initial ui-controller.h from the preceding lesson. Include hud.h, retain std::optional<Hud> hud_ after the session member, and add score/health values with setters. After creating the session with its initial view in init_simulation(), construct the HUD with hud_.emplace(*session_). Update it before recording the combined scene. During final cleanup, reset hud_ before closing the session.

The complete controller is maintained with the tutorial game:

#pragma once

#include "hud.h"

#include <cheryl/core/engine/engine-context.h>
#include <cheryl/core/game-framework/tick-context.h>
#include <cheryl/ui/tgui/scene.h>
#include <cheryl/ui/tgui/session.h>

#include <future>
#include <memory>
#include <optional>

namespace Tutorial {
    class UIController final {
        static constexpr CE::Input::Routing::TargetId tgui_target_ = 2;

        CE::Runtime::Context& engine_;
        CE::Runtime::PlatformDispatcher::Submission platform_;
        CE::UI::TGUI::SceneUploader uploader_;
        CE::UI::TGUI::Scene scene_;
        std::future<CE::UI::TGUI::Scene> pending_;
        std::unique_ptr<CE::UI::TGUI::Session> session_;
        std::optional<Hud> hud_;
        int score_ = 0;
        unsigned int health_ = 100;

    public:
        // Construct during Game::init_platform(); create toolkit objects on simulation.
        explicit UIController(CE::Runtime::Context& engine)
        : engine_(engine), platform_(engine.platform_dispatcher().submission()), uploader_(engine.resources()) {}

        void set_score(int value) { score_ = value; }
        void set_health(unsigned int value) { health_ = value; }

        void init_simulation() {
            session_ = std::make_unique<CE::UI::TGUI::Session>(engine_, tgui_target_, CE::UI::TGUI::SessionOptions{.automatic_view = true});
            hud_.emplace(*session_);
        }

        void update(const CE::Game::TickContext& tick) {
            // This example has a passive HUD. Interactive components choose focus/pointer policy here.
            session_->handle_input(tick.input.records(), false);
            session_->update_time(tick.delta_seconds);
            hud_->update(score_, health_);

            static_cast<void>(CE::UI::TGUI::adopt_scene(pending_, scene_));
            if (!pending_.valid())
                pending_ = uploader_.submit(platform_, session_->record());
        }

        void append(CE::Rendering::FrameWriter& frame) const { scene_.write(frame); }

        void close_after_quiescence() {
            // Drop component-held toolkit references before the GUI/session releases its own.
            hud_.reset();
            if (session_) {
                session_->close_after_quiescence();
                session_.reset();
            }
            scene_ = CE::UI::TGUI::Scene{};
        }
    };
}

This controller has one passive HUD. It feeds the complete input batch, selects no pointer delivery, and requests no keyboard focus, so gameplay actions stay available. Add a menu/chat component beside hud_, passing the same session. Use the shared owner’s focus policy when interactive components need keyboard or pointer input. TGUI permits one session per process; component ownership fits within that single session.

Connect game state to the widgets

Game rules supply presentation values; the shared UI owner then records the updated widgets as part of the whole interface:

        flowchart LR
    accTitle: From game values to HUD presentation
    accDescr: The game passes health and score to the UIController, which updates the HUD's widgets before recording and publishing the shared session's scene.
    game["Health and score"] --> controller["UIController"]
    controller -->|update values| hud["HUD widgets"]
    hud -->|record| scene["Shared UI scene"]
    

Keep the game’s ui_ member and lifecycle calls from the UIController lesson. Remove First Game’s raw score_ generation, font/layout/upload block, direct text draw, and generation cleanup. Keep an application score and health value:

int points_ = 0;
unsigned int health_ = 100;

After quit handling and gameplay updates, set the presentation values before the existing ui_->update(tick) call:

if (tick.input.button(next_cell_).pressed() && points_ < std::numeric_limits<int>::max())
    ++points_;
ui_->set_score(points_);
ui_->set_health(health_);
ui_->update(tick);

Include <limits> for the score bound. In First Game, Space is next_cell_; the completed tutorial game calls its score action score_. The maintained game implementation shows the complete controller/Character/HUD composition.

Append the retained UI scene

The game still calls ui_->append(frame) after the world pass. One UI scene contains all attached widgets and supplies its own screen projection, so camera movement does not move the HUD. The first scene can be empty until upload finishes. The controller retains the preceding complete scene while a replacement is pending.

Remove a component during play

When removing a HUD during play, call hud_->remove() on simulation, then reset the component and stop its update branch in the enclosing owner. That detaches only its widgets and keeps the session available to menus, chat, and other components. Destroying a component’s handles alone does not detach widgets still owned by the GUI.

Release and observe

Final teardown follows ui_->close_after_quiescence(): simulation has joined, the controller drops component-held widget references, and session closure releases the GUI’s remaining ownership. This final path does not call live session methods from the platform thread. The input and graphics dependencies remain alive through runtime cleanup, including partial initialization.

Expect Score: 0 at the top-left and a full health bar at the top-right. Space advances the score; setting health_ to 25 shows a quarter-full bar. Resize the window and move the world camera: the widgets should follow their screen corners, while gameplay keys continue to work. Check both runtime modes for input and cleanup.

The maintained tutorial game implements this shared session and widget component. For interactive widgets, use the TGUI demo. Continue with abstract controller interfaces when the game needs a common contract for its concrete controller roles.