Skip to content

The HUD

Lesson 14.3

The HUD is everything drawn over the world while you play: hearts, the hotbar, the crosshair. Mods can add their own HUD layers: a counter in the corner, a compass bar, a cooldown icon.

A first layer

cl.on("lifecycle.client_setup", function()
  cl.hud.register_layer{
    id = "hello",
    render = function(g)
      g:text("Hello, HUD!", 4, 4, 0xFFFFFF, true)
    end,
  }
end)
  • id names the layer (it becomes my_mod:hello).
  • render is called every frame with a drawing context, g.
  • g:text(text, x, y, colour, shadow) draws text. x and y are in GUI pixels from the top-left corner, and shadow = true gives the vanilla text shadow.

GUI pixels follow the player’s GUI scale setting, so your layer has the same size as the vanilla HUD at every scale. g:width() and g:height() give the screen size in those pixels.

Layers are hidden with F1 like the rest of the HUD.

Colours

Colours are 0xAARRGGBB: alpha (opacity), then red, green and blue. If you leave out the alpha, as in 0xFFFFFF, the colour is fully opaque. For see-through colours, write the alpha: 0x80000000 is black at half opacity.

Drawing

Method Draws
g:text(s, x, y, colour, shadow) Text; returns the x where it ended.
g:text_centered(s, cx, y, colour, shadow) Text centred on cx.
g:fill(x0, y0, x1, y1, colour) A filled rectangle from corner to corner.
g:fill_gradient(x0, y0, x1, y1, top, bottom) A rectangle fading from the top colour to the bottom one.
g:outline(x, y, w, h, colour) A 1-pixel frame.
g:item_by_name(id, x, y) An item icon, 16 x 16.
g:item(stack, x, y, decorations) A stack’s icon, with count and durability bar if decorations is true.
g:sprite(id, x, y, w, h) A GUI sprite, like "minecraft:hud/heart/full".
g:tooltip(s, x, y) A vanilla tooltip box.

cl.gui.text_width(s) gives a text’s width in pixels, and cl.gui.line_height() the height of a line. Together they make layouts that fit:

render = function(g)
  local label = "Rubies: 12"
  local w = cl.gui.text_width(label)
  local x = g:width() - w - 6           -- right edge, 6 pixels in
  g:fill(x - 3, 3, x + w + 3, 15, 0x80000000)
  g:text(label, x, 5, 0xE0115F, true)
end

Your own sprites go into assets/my_mod/textures/gui/sprites/. assets/my_mod/textures/gui/sprites/ruby_icon.png is drawn with g:sprite("my_mod:ruby_icon", x, y, 16, 16).

Scaling and moving

g:push_pose() saves the current position and size, g:translate(x, y) and g:scale(sx, sy) change them, and g:pop_pose() goes back:

g:push_pose()
g:scale(2, 2)
g:text("BIG", 10, 10, 0xFFFF55, true)   -- drawn at 20, 20 and twice the size
g:pop_pose()

Every push_pose needs a pop_pose, or everything drawn after it is moved as well.

Keep render cheap

render runs every frame: 60, 144 or more times a second. Don’t search the inventory or the world in it. Work out values in tick.client_post (20 times a second) and only draw them in render:

local rubies = 0

local function count_rubies(player)
  local n = 0
  for slot = 0, 40 do
    local stack <close> = player:inventory_get(slot)
    if stack and stack:is("my_mod:ruby") then n = n + stack:count() end
  end
  return n
end

cl.on("lifecycle.client_setup", function()
  cl.on("tick.client_post", function()
    local me = cl.entity.client_player()
    rubies = me and count_rubies(me) or 0
  end)

  cl.hud.register_layer{
    id = "rubies",
    visible = function() return rubies > 0 end,
    render = function(g)
      g:item_by_name("my_mod:ruby", 4, 4)
      g:text("x" .. rubies, 22, 8, 0xFFFFFF, true)
    end,
  }
end)

visible is asked before drawing. While it returns false, render isn’t called at all, which costs nothing.

Where the layer goes

Without more options, a layer is drawn on top of the vanilla HUD. above and below put it between vanilla layers:

cl.hud.register_layer{
  id = "mana",
  above = "minecraft:hotbar",
  render = function(g) ... end,
}

To sit just above the hearts and the food bar like vanilla’s armour and air bubbles, ask how high they reach: cl.hud.left_height() (hearts side) and cl.hud.right_height() (food side) are the heights above the bottom of the screen.

register_layer returns an object; call :cancel() on it to remove the layer again.

Try it

Draw a small clock in the top-right corner: a dark box with the time of day as HH:MM, where 0 ticks is 06:00 and the day has 24000 ticks. Compute the text in the client tick.

Show a solution
local clock = ""

cl.on("lifecycle.client_setup", function()
  cl.on("tick.client_post", function()
    local world = cl.level.client_level()
    if not world then clock = "" return end
    local t = world:day_time() % 24000
    local minutes = math.floor((t / 1000 + 6) * 60) % (24 * 60)
    clock = ("%02d:%02d"):format(minutes // 60, minutes % 60)
  end)

  cl.hud.register_layer{
    id = "clock",
    visible = function() return clock ~= "" end,
    render = function(g)
      local w = cl.gui.text_width(clock)
      local x = g:width() - w - 6
      g:fill(x - 3, 3, x + w + 3, 15, 0x90000000)
      g:text(clock, x, 5, 0xFFFFFF, true)
    end,
  }
end)

1000 ticks are one in-game hour, so t / 1000 + 6 is the hour, and * 60 turns it into minutes.

Recap: cl.hud.register_layer{ id, render, visible } draws every frame with g:text, g:fill and friends; compute values in the client tick and keep render cheap.