Skip to content

Menus and HUDs

Panels, prompts and heads-up displays. Everything here is drawn on the player's own machine, so navigation and toggles are instant.

The complete catalogue of nodes and style keys is in the HUD reference. This page is how to use them.

A panel that appears on click

luau
--!strict
local self = require("self")

self:on_touch(function(p)
    p:show_hud({
        id = "sign",
        anchor = "center",
        modal = true,
        pages = { main = {
            { "text", "Mind the step", size = 22, bold = true },
            { "text", "The floor below is being repaired.", wrap = true },
            { "button", "Understood", close = true },
        } },
    })
    p:show_cursor()
end)

show_cursor reveals the pointer and freezes the avatar. Esc always closes what a script opened — the player owns their pointer, and no script can take it.

The text of a node comes second, by position. { "text", "Hello" } works; { "text", text = "Hello" } creates an empty node that takes up space and draws nothing. This is the most common HUD mistake.

Several pages

Page changes never touch your script:

luau
p:show_hud({
    id = "menu",
    anchor = "center",
    modal = true,
    start = "main",
    pages = {
        main = {
            { "text", "Workshop", size = 22, bold = true },
            { "button", "Tools",   goto = "tools" },
            { "button", "Close",   close = true },
        },
        tools = {
            { "text", "Tools", size = 18, bold = true },
            { "button", "Take a hammer", emit = "take", data = { item = "hammer" } },
            { "button", "Back", back = true, style = "ghost" },
        },
    },
})

Only emit costs a message. goto, back, close, set and add are all local.

Receiving a button

luau
local world = require("world")

self:on_hud(function(p, event, data)
    if event ~= "take" then return end

    world:spawn({
        model = tostring(data.item),
        hold = p.id,
        at = "right_hand",
        life = 0,
    })
end)

Pressing a button on your HUD counts as interacting with your object, which is why handing something over from a menu is allowed.

Trust event and the player, not data. data is whatever the button carried; check it before using it.

A live number

Do not rebuild the HUD to change a value — that would throw away which page they were on. Push it:

luau
--!strict
local self = require("self")
local players = require("players")

local score = {}

local function show(p)
    p:show_hud({
        id = "score",
        anchor = "top_right",
        size = { 160, 0 },
        vars = { points = score[p.id] or 0 },
        pages = { main = {
            { "text", "Score: {points}", size = 18, bold = true },
        } },
    })
end

players:on_enter(function(p)
    score[p.id] = 0
    show(p)
end)

local function award(id: string, n: number)
    score[id] = (score[id] or 0) + n

    local who = players:get(id)
    if who then
        who:update_hud("score", { points = score[id] })
    end
end

{points} in the text is replaced by the variable. update_hud merges by key, so keys you do not mention keep their values.

A prompt hanging in the world

Set world and the panel becomes a billboard on an object rather than a screen corner:

luau
p:show_hud({
    id = "shop_sign",
    world = { height = 2.2, distance = 12, fade = 18, gaze = 0 },
    size = { 220, 0 },
    pages = { main = {
        { "text", "General store", size = 16, bold = true, align = "center" },
    } },
})
KeyMeaning
heightmetres above the object
distancestop drawing past here
fadestart dissolving here
gazeonly while looked at, within this many degrees. 0 = any angle

gaze = 0 is right for a sign, which should be readable from anywhere. gaze = 20 is right for a pickup prompt, which should only appear for the thing you are actually looking at.

Hold to confirm

luau
vars = { fill = 0 },
pages = { main = {
    { "ring", var = "fill", size = 60, thickness = 4 },
    { "text", "Hold F to detonate", size = 12, align = "center" },
    { "hold", key = "f", dur = 2.0, var = "fill", emit = "boom" },
} },

The hold node draws nothing — it drives the variable that the ring shows. Nothing is sent while the key is held; only the completed emit arrives.

Showing it to everyone nearby

luau
local players = require("players")

for _, p in ipairs(players:nearby(20)) do
    p:show_hud(WARNING)
end

Styling without repeating yourself

luau
styles = {
    title = { size = 22, bold = true },
    card  = { bg = "#1b2330", rounding = 12, pad = 12, shadow = true },
},
pages = { main = {
    { "text", "Workshop", class = "title" },
    { "column", {
        { "text", "Everything here is free." },
        { "button", "Take one", emit = "take" },
    }, class = "card" },
} },

A node's own key always beats its class.

Hiding parts conditionally

luau
vars = { coins = 4 },
pages = { main = {
    { "text", "Not enough coins", visible = { "<", "coins", 10 } },
    { "button", "Buy", emit = "buy", enabled = { ">=", "coins", 10 } },
} },

Both are evaluated on the player's machine, so they update the instant a variable changes.

Common mistakes

SymptomCause
a label is invisibletext given as text = instead of in position two
the HUD never appearsmissing id or pages
a button does nothingit has no emit, goto, back, close, set or add
the number never updatesrebuilt with show_hud instead of pushed with update_hud
show_cursor refusedthis is a social world; it only works in an experience

See also

Hungrit scripting documentation.