Skip to content

Screens and menus

Lesson 14.4

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

local function open_hello()
  cl.screen.open{
    title = "Hello",
    pause = false,
    init = function(s)
      local cx = s:width() // 2
      s:label(cx, 40, "This is my first screen.", 0xFFFFFF, true)
      s:button(cx - 100, s:height() - 40, 200, 20, "Done", function()
        s:close()
      end)
    end,
  }
end

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

cl.on("tick.client_post", function()
  while key:consume_click() do open_hello() end
end)

The screen description:

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

Widgets

Widgets look and behave like vanilla’s. Each is created on the screen s and returns a widget object.

init = function(s)
  local cx = s:width() // 2

  s:button(cx - 100, 50, 200, 20, "Click me", function(w)
    w:set_label("Clicked!")
  end)

  s:checkbox(cx - 100, 80, "Show sparkles", true, function(w)
    sparkles = w:value() == 1
  end)

  s:slider(cx - 100, 110, 200, 20, "Volume", 0, 100, 50, 5, function(w)
    volume = w:value()
  end)

  s:text_field(cx - 100, 140, 200, 20, "Your name", 32, function(w)
    name = w:text()
  end)
end
Widget Arguments Its function gets
s:button(x, y, w, h, label, on_press) the button
s:checkbox(x, y, label, checked, on_change) the checkbox; w:value() is 1 or 0
s:slider(x, y, w, h, label, min, max, value, step, on_change) step snaps the value, 0 for smooth the slider; w:value() is the number
s:text_field(x, y, w, h, hint, max_length, on_change) hint shows while empty the field; w:text() is the text
s:label(x, y, text, colour, centered) (no function)

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

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

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:

render = function(s, g, mx, my)
  g:text_centered("My Mod", s:width() // 2, 20, 0xFFFFFF, true)
  g:item_by_name("my_mod:ruby", s:width() // 2 - 8, 160)
end

Remember values outside the screen

The screen is rebuilt on every resize, so keep its state in variables outside it, and read them when building the widgets:

local volume = 50

local function open_settings()
  cl.screen.open{
    title = "Settings",
    init = function(s)
      s:slider(s:width() // 2 - 100, 60, 200, 20, "Volume", 0, 100, volume, 5, function(w)
        volume = w:value()
      end)
    end,
  }
end

Bigger layouts: RML documents

For screens with a lot of content, you can write the layout as a document, similar to HTML and CSS, and open it with cl.rml.open{ path = "assets/my_mod/gui/shop.rml" }. Your script fills in values with doc:set_text(id, text) and gets clicks in on_event. 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.

local function open_shop(player)
  local shop = cl.menu.open(player, {
    kind = "chest_9x1",
    title = "Ruby Shop",
    on_click = function(menu, who, slot)
      if slot >= 0 and slot < 9 then
        who:give("my_mod:ruby")
        who:send_action_bar("+1 ruby")
      end
      return false   -- don't let vanilla move the item
    end,
  })
  for slot = 0, 8 do
    shop:set_item(slot, "my_mod:ruby")
  end
end

Open it from server code, for example in a command (open_shop(ctx:player())).

  • kind is the size: "chest_9x1" up to "chest_9x6", "dispenser_3x3" or "hopper".
  • on_click(menu, player, slot, button, click) runs for every click. The slot numbers start at 0 for your slots, then come the player’s inventory slots. Return true to let the click happen normally, false to cancel it.
  • on_close(menu, player) runs when it closes.
  • Menus are locked by default: nothing can be taken out or put in unless on_click returns true. locked = false turns that off.

menu:set_item(slot, item) puts an item into a slot (an id or a stack), menu:get_item(slot) reads it, and menu:close() closes the menu.

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
local size = 6

local function open_roller()
  cl.screen.open{
    title = "Roller",
    pause = false,
    init = function(s)
      local cx = s:width() // 2
      s:slider(cx - 100, 60, 200, 20, "Size", 1, 10, size, 1, function(w)
        size = math.floor(w:value())
      end)
      s:button(cx - 100, 90, 200, 20, "Roll", function(w)
        w:set_label("Rolled " .. math.random(1, size))
      end)
    end,
  }
end

step = 1 makes the slider jump between whole numbers. math.floor turns the value into an integer for math.random.

Recap: cl.screen.open{ title, init, render } builds a client screen with vanilla widgets, rebuilt in init on every resize; cl.menu.open(player, { kind, on_click }) opens a server-side chest menu.