Skip to content

Screens and menus

Lesson 9.3

A screen is a full window over the game, like the options menu: the mouse is free and players click buttons. Mods can build their own screens in the client, or open chest-style menus from the server.

Opening a screen

class HelloScreenMod final : public clight::Mod {
public:
    void open_hello() {
        clight::screen::open({
            .title = "Hello",
            .pause = false,
            .init = [](clight::Screen s) {
                int32_t cx = s.width() / 2;
                s.widget_label(cx, 40, "This is my first screen.", 0xFFFFFFFF, true);
                s.button(cx - 100, s.height() - 40, 200, 20, "Done", [s] { s.close(); });
            },
        });
    }
};

Open it from client code, for example on a keybind (see Keys and client events):

clight::keybind::add("my_mod:hello", "key.keyboard.j", [this] { open_hello(); });

clight::screen::open takes a clight::ScreenSpec. You write it with designated initializers, .name = value, in the order of this table, and leave out what you don’t need. The functions are lambdas and may capture what they need ([this], values):

Field Default Means
.title A translation key, or plain text. Read out by the narrator.
.pause true Pause a singleplayer game while open.
.background true Darken the world behind it.
.close_on_esc true Esc closes it.
.init(s) Build the widgets.
.render(s, g, mouse_x, mouse_y, partial_tick) Draw extra things every frame.
.tick(s) Runs 20 times a second while open.
.key(s, key, scancode, modifiers) A key was pressed. Return true if you used it.
.char_typed, .mouse, .scroll Typed characters, mouse clicks and the wheel. Return true if you used them.
.close(s) The screen closed.

init runs when the screen opens and again whenever the window is resized, after the old widgets were removed. So build all widgets in init, and place them from s.width() and s.height(); then they move to the right place on every resize.

When the screen closes, its lambdas and those of its widgets are freed. open returns an Expected<clight::Screen> if you want to close the screen from somewhere else (s.close()).

Widgets

Widgets look and behave like vanilla’s. Each is created on the screen s and comes back as an Expected<clight::Widget>, in case you want to change it later. Their callbacks are lambdas too; they get the widget, or nothing if you leave the parameter out.

class SettingsMod final : public clight::Mod {
public:
    void open_settings() {
        clight::screen::open({.title = "Settings", .init = [this](clight::Screen s) {
            int32_t x = s.width() / 2 - 100;
            s.button(x, 50, 200, 20, "Click me", [](clight::Widget w) { w.set_label("Clicked!"); });
            s.checkbox(x, 80, "Show sparkles", sparkles_, [this](clight::Widget w) { sparkles_ = w.value() == 1.0; });
            s.slider(x, 110, 200, 20, "Volume", 0, 100, volume_, 5, [this](clight::Widget w) {
                volume_ = static_cast<int>(w.value());
            });
            s.text_field(x, 140, 200, 20, "Your name", 32, [this](clight::Widget w) { name_ = w.text().value_or(""); });
        }});
    }

private:
    int volume_ = 50;
    bool sparkles_ = true;
    std::string name_;
};
Widget Its callback reads
s.button(x, y, w, h, label, on_press)
s.checkbox(x, y, label, checked, on_change) widget.value() is 1 or 0
s.slider(x, y, w, h, label, min, max, value, step, on_change) widget.value() is the number; step snaps it, 0 for smooth
s.text_field(x, y, w, h, hint, max_length, on_change) widget.text(); hint shows while empty
s.widget_label(x, y, text, argb, centered) (no callback)

A slider shows its value in the label, like Volume: 50. Labels are plain text, not translation keys.

Every widget can be changed later through its clight::Widget: set_label, set_tooltip, set_active(false) (greyed out), set_visible, set_value, set_text, set_bounds and remove.

Keep state outside the screen

The widgets are rebuilt on every resize, so keep the values in members of your mod (like volume_ above) and read them when building the widgets. Then a resize never loses what the player chose.

Drawing on a screen

.render gets the same drawing context as a HUD layer (see The HUD), plus the mouse position. It is called after the background and before the widgets:

clight::screen::open({
    .title = "My Mod",
    .render = [](clight::Screen s, clight::Gui g, int32_t, int32_t, float) {
        g.text_centered("My Mod", s.width() / 2, 20, 0xFFFFFFFF, true);
        g.item_by_name("my_mod:ruby", s.width() / 2 - 8, 170);
    },
});

Bigger layouts: RML documents

For screens with a lot of content, you can write the layout as a document, similar to HTML and CSS, in assets/my_mod/gui/shop.rml, and open it with clight::rml::open. Your code fills in values with rml_set_text(id, text) and gets clicks in the descriptor’s on_event:

void open_shop() {
    cl_rml_desc d = CL_RML_DESC_INIT;
    d.path = CL_STR("assets/my_mod/gui/shop.rml");
    d.on_event = [](cl_rml_doc, cl_str element_id, cl_str event, cl_str, void*) {
        clight::log::info("{} on {}", clight::sv(event), clight::sv(element_id));
    };
    if (auto doc = clight::rml::open(&d)) {
        doc->rml_set_text("balance", "12 rubies");
    }
}

The reference docs cover the format; for most mods, the widgets above are enough.

A menu is a chest-style window that the server opens for a player: item slots instead of buttons. It uses vanilla’s chest menus, so it even works for players without your mod. That makes it the right tool for shops, kits and choices.

void open_shop_menu(clight::Entity player) {
    auto shop = clight::menu::open(player, {
        .kind = CL_MENU_CHEST_9X1,
        .title = "Ruby Shop",
        .on_click = [](clight::Menu, clight::Entity who, int32_t slot, int32_t, cl_click_type) {
            if (slot >= 0 && slot < 9) {
                who.give("my_mod:ruby");
                who.send_action_bar("+1 ruby");
            }
            return false;   // don't let vanilla move the item
        },
    });
    if (!shop) return;

    clight::ItemStack ruby = clight::item("my_mod:ruby");
    for (int32_t slot = 0; slot < 9; ++slot) shop->set_item(slot, ruby);
}

Open it from server code, for example in a command (open_shop_menu(ctx.player())). clight::MenuSpec has these fields:

  • .kind is the size: CL_MENU_CHEST_9X1 up to CL_MENU_CHEST_9X6 (the default is CL_MENU_CHEST_9X3), CL_MENU_DISPENSER_3X3 or CL_MENU_HOPPER.
  • .title is plain text.
  • .locked: menus are locked by default, so nothing can be taken out or put in unless on_click returns true. .locked = false turns that off.
  • .on_click(menu, player, slot, button, click) runs for every click. Slot numbers start at 0 for your slots, then come the player’s inventory slots; -999 is a click outside the window. Return true to let the click happen normally, false to cancel it.
  • .on_close(menu, player) runs when it closes. After that the menu’s lambdas are freed.

set_item copies the stack into the slot, so one stack can fill all nine. menu.get_item(slot) gives you a clight::ItemStack copy back, menu.size() the number of slots, and menu.close() closes it.

Try it

Make a client screen with a slider “Size” from 1 to 10 and a button “Roll”. Pressing the button sets its own label to a random number between 1 and the slider’s value.

Show a solution
#include <clight.hpp>
#include <random>

class RollerMod final : public clight::Mod {
public:
    void open_roller() {
        clight::screen::open({.title = "Roller", .pause = false, .init = [this](clight::Screen s) {
            int32_t x = s.width() / 2 - 100;
            s.slider(x, 60, 200, 20, "Size", 1, 10, size_, 1, [this](clight::Widget w) { size_ = static_cast<int>(w.value()); });
            s.button(x, 90, 200, 20, "Roll", [this](clight::Widget w) {
                int n = std::uniform_int_distribution<int>(1, size_)(rng_);
                w.set_label(std::format("Rolled {}", n));
            });
        }});
    }

private:
    int size_ = 6;
    std::mt19937 rng_{12345};
};

step = 1 makes the slider jump between whole numbers. size_ lives in the mod, not in the screen, so the slider keeps its position when the window is resized.

Recap: clight::screen::open({...}) builds a client screen whose widgets are created in .init (again on every resize) with s.button, s.slider and friends; clight::menu::open(player, {...}) opens a server-side chest menu that works for every player. All their callbacks are lambdas, freed when the screen or menu closes.