Repository navigation
LuaUI Controls
| Control | Created with | What it is for |
|---|---|---|
| Label | col:Label(text, opts) |
a line of text - captions, readouts, status |
| Button | col:Button(text, opts) |
something to click, drawn or skinned |
| Text input | col:TextInput{...} |
a single-line field the player types into |
| Image | win:handle():addImage(...) |
decoration that ignores the mouse |
| Panel | win:Panel{...} |
a sub-area with its own controls, optionally scrolling |
The rest of the page is shared ground: Callbacks fire per frame, The raw handle is what the wrapper sits on, The UI table and Mouse and keyboard are global helpers.
Controls are created through a layout group, which positions them - none of them take x/y.
local col = win:Column{ top = 40, padding = 16, spacing = 6, align = "center" }
local row = win:Row{ top = 120, padding = 16, spacing = 8 }| Option | Values | Default | Configures |
|---|---|---|---|
top |
number, canvas units | padding |
where the group starts, from the parent's top edge |
y |
number, canvas units | - | alias for top
|
x |
number, canvas units | padding |
left edge of the group |
padding |
number, canvas units | 12 |
default left margin, and the gap kept from the right edge |
spacing |
number, canvas units | 6 |
gap between consecutive controls |
align |
"left", "center", "right"
|
"left" |
horizontal placement; vertical groups only |
w |
number, canvas units | up to the usable width | maximum width a control may take |
A Column stacks downwards, a Row runs left to right. Both work identically on a
window and on a panel. Use several groups when a window has distinct areas - a
header column, then a panel, then a footer row.
Option tables take w and h; the controls they produce expose
.width and .height. Windows are the odd one out and take w with
height:
| Where | Width | Height |
|---|---|---|
ui.Window{...} |
w |
height (or minHeight / maxHeight) |
win:Panel{...} |
w |
h |
Label / Button / TextInput options |
w |
h |
Column / Row options |
w |
- |
| Any control or window afterwards | .width |
.height |
local btn = col:Button("Convert", { w = 120, h = 26 })
btn.width = 140 -- not btn.wCentre alignment centres on the box, but a control is always kept clear of the scrollbar, so a wide control in a narrow scrolling panel drifts left rather than sliding under the bar.
Every option that sets a text size takes it in screen pixels. Leaving it out,
or passing 0, uses the client's base size, which the server owner sets in
config.ini under [InstallFont] Size and which is 12 unless they changed it.
Sizes are not canvas units. A box scales with the window, letters do not, so a
label at fontSize = 16 is sixteen pixels tall at every resolution.
Each size is rasterised from the outline separately, so it is a real font at that size rather than a stretched bitmap, and it costs nothing per frame once drawn. Anything below 6 or above 128 pixels is clamped.
Four presets are defined for you as globals, worked out from the base size so a script built on them keeps its proportions when the server owner changes it:
| Preset | Size | At the default base of 12 |
|---|---|---|
FontS |
75% of the base | 9 |
FontM |
the base size | 12 |
FontL |
125% of the base | 15 |
FontLB |
140% of the base | 17 |
Letters are drawn on whole pixels, so the rasteriser rounds the font's design onto the pixel grid. That rounding is a fraction of a pixel whatever the size, which means it costs proportionally more the smaller the text is. Above the base size you will not notice it. Below it, a single rounding can swallow a whole row of a lowercase letter while its width stays where it was, and the result reads as flattened rather than simply small.
Which sizes this happens at depends entirely on the font, because it depends on where that font's x-height falls between two pixels. Batang is a clear example: at 12 pixels its lowercase body is 7 by 6 pixels, at 11 it is 7 by 5, the same width and one row shorter. Many CJK families ship hand-drawn bitmaps for exactly these small sizes because their outlines do not survive there. The client does not use those bitmaps: they cannot be scaled or emboldened, so they would sit oddly next to the rest of the interface.
What to do about it:
- Keep body text at the base size and make things stand out by going up, not down. The game's own interface does not go below its base size either.
- Treat
FontSas the exception rather than a default. It is the one preset that lands below the base size, so it inherits this whole problem. - If a smaller size really is the right call, look at it before you ship it. Drawing the same string at a ladder of sizes, one under the other, shows in seconds where a given font stops holding its shape. The answer differs per font, so it is worth checking against the one your server actually ships.
local lbl = col:Label("Coins: 0", {
w = 200, h = 16,
align = ALIGN_CENTER,
color = RGBA(220, 220, 220, 255),
fontSize = 11,
bold = true,
slide = false,
})| Option | Values | Default | Configures |
|---|---|---|---|
w |
number, canvas units | the group's usable width | box width |
h |
number, canvas units | 16 |
box height |
align |
ALIGN_LEFT, ALIGN_RIGHT, ALIGN_CENTER
|
ALIGN_CENTER |
where the text sits in the box |
color |
RGBA(r, g, b, a), each 0-255 |
RGBA(255, 255, 255, 255) |
text colour |
fontSize |
number, screen pixels | the base size | height of the letters, see Font size |
bold |
true / false
|
false |
bold face |
slide |
true / false
|
true |
slide overflowing text sideways on hover |
| Property | Type | Access | What it is |
|---|---|---|---|
.id |
integer | read | the id the layout group assigned |
.text |
string | read/write | the string on screen |
.visible |
boolean | read/write | whether it is drawn at all |
.x .y .width .height
|
number, canvas units | read/write | its box |
.slide .bold
|
boolean | read/write | the slide and bold options |
.color |
RGBA(r, g, b, a) |
write | text colour; assigning works, reading gives nil
|
.fontSize |
number, screen pixels | read/write | the fontSize option; 0 means the base size |
:setColor(c) :setFontSize(n) :setAlign(a) :setSlide(b)
|
call | - | setter form of the matching options |
slide. Hovering a label whose text overflows its box slides the text sideways
so the rest can be read. The overflow test is conservative and fires on text that
looks like it fits, so anything that should sit still - a readout, a status line -
wants slide = false.
Raising scale means raising h. The font is rasterised at the size you ask
for, so a bigger scale really is bigger type - and text is clipped to its box, so
a row still sized for the base font loses the tops of the letters. Move both
together: { h = 20, scale = 1.15 }.
An empty label draws nothing. That makes it free to keep a status line in the
layout permanently and simply write to it when there is something to say. It also
means a label you seeded with "" looks broken until the first update, so seed it
with real text if it should be visible immediately.
Give a label the column's full width rather than a snug one. A centred label only centres while the text fits; once it overflows, drawing starts at the left edge and the line reads as shifted.
ALIGN_RIGHT earns its place on figures. A caption on the left and its value on
the right, both in the same box, keeps the digits in one column as they grow -
where a centred number shifts sideways every time it gains a digit:
local row = win:Row{ top = 60, padding = 12 }
row:Label("today", { w = 80, align = ALIGN_LEFT, color = DIM })
row:Label(amount, { w = 80, align = ALIGN_RIGHT, color = BRIGHT })local btn = col:Button("Convert", {
w = 120, h = 26,
color = RGBA(255, 255, 255, 255),
scale = 1.0,
onClick = function()
...
end,
})| Option | Values | Default | Configures |
|---|---|---|---|
w |
number, canvas units | 120 |
button width |
h |
number, canvas units | 26 |
button height |
color |
RGBA(r, g, b, a), each 0-255 |
RGBA(255, 255, 255, 255) |
label colour |
fontSize |
number, screen pixels | the base size | label font size, see Font size |
faceColor |
RGBA(r, g, b, a) |
the built-in blue | face colour for a button with no artwork; hover and press are derived from it |
faceColor fills the whole button, not the text behind it: the caption is
centred in the box you gave with w and h, and there is no padding to set. Give
the button room, because a caption wider than its box spills over the edges rather
than wrapping or scrolling the way a label does. A button with setImage artwork
ignores the colour entirely.
| url | string | none | opens a web page after asking the player, see Links out of the game |
| urlPrompt | string | the wording from CustomText | the sentence shown above the address |
| urlParent | window | none | centre the confirmation on that window instead of on the screen |
| onClick | function | none | called when the button is pressed |
| Property | Type | Access | What it is |
|---|---|---|---|
.id |
integer | read | the id the layout group assigned |
.click |
function, or nil
|
read/write | the click handler, replaceable at any time |
.render |
function, or nil
|
read/write | draw callback, run after the button draws itself |
.hover |
boolean | read | cursor is over the button |
.state |
integer, one of ui.STATE
|
read | see below |
.visible |
boolean | read/write | whether it is drawn at all |
.x .y .width .height
|
number, canvas units | read/write | its box |
.faceColor |
RGBA(r, g, b, a) |
write | face colour, after creation |
:setText(text, color, fontSize) |
call | - | the label and its colour and size |
:setImage(state, slot, sx, sy, sw, sh) |
call | - | artwork for one state, see Designing a window |
States are named in ui.STATE - use those rather than the bare numbers, both when
reading .state and when calling setImage:
| Constant | Value | When |
|---|---|---|
ui.STATE.NORMAL |
0 |
at rest |
ui.STATE.INACTIVE |
1 |
reserved - see below |
ui.STATE.HOVER |
2 |
cursor over it |
ui.STATE.PRESSED |
3 |
held down |
A state with no image of its own falls back to NORMAL, so supplying only the
resting artwork is a valid way to skin a button.
INACTIVE completes the numbering but is not reachable from a script: the button
never enters it on its own, and .state is read-only. An image assigned to it
would never be drawn.
Disable a button by replacing its handler rather than hiding it:
btn.click = function()
if busy then return end
...
endTo make that visible, swap the NORMAL artwork for the greyed cut while it is
disabled - .state is read-only, so a disabled look is something you paint, not
a state you set.
local input = col:TextInput{ w = 180, h = 20, numeric = true, maxValue = 100000 }.fontSize sets the size of both the text and the placeholder, in screen pixels;
the caret follows it. See Font size.
Length rules, character filters, IME and encoding: Text input.
A button with a url asks before it sends the player anywhere. Clicking it opens a
confirmation in the middle of the screen showing the address in full, with Continue
and Cancel.
col:Button("Our website", {
url = "https://www.igcn.mu/",
urlParent = win, -- centre it on this window instead of the screen
})onClick still runs if you set both; the confirmation comes up afterwards.
Only http:// and https:// are ever opened. Anything else is refused, because
the shell would happily start a program for other schemes. The check happens when the
page is opened, so a button with a rejected address shows the confirmation and then
does nothing.
The same box is available on its own, which is what the button uses:
ui.ConfirmUrl("https://www.igcn.mu/", {
title = "Leaving the game",
prompt = "This link opens in your web browser:",
yes = "Continue", no = "Cancel",
parent = win, -- omit to centre on the screen
dim = 45, -- how dark the rest of the screen goes
dimClickThrough = false,
})Leave the wording out and it comes from CustomText.xml, section UrlConfirm, ids
0 to 3 - title, prompt, confirm button, cancel button. The English text is built in as
a fallback, so a client without that section still shows a sensible box. See
Custom text.
The window is modal while it is up, so nothing underneath reacts until the player
answers, and it can be dragged by its title bar. Passing dimClickThrough = true
drops the modality, which also means the box can be buried by clicking the window
underneath - see Windows.
A long address does not overflow the box: the line it sits on scrolls sideways while the cursor is over it, the same behaviour any label has by default.
Decoration that does not react to the mouse. Created on the raw handle, since a layout group has nothing to lay out for it:
local icon = win:handle():addImage(win:nextId(), 12, 40, 32, 32)
icon:setImage(R.slot.reward, 0, 0, 32, 32)setImage(slot, sx, sy, sw, sh) picks the region in bitmap pixels; the control's
own size, in canvas units, decides how it is stretched.
Properties: .id (read-only), .slot (read-only), .x, .y, .width,
.height, .visible.
A box that clips and optionally scrolls its content.
local panel = win:Panel{ x = 14, y = 200, w = 252, h = 150, back = RGBA(0,0,0,90) }Options, scrollbars and styling: Scrolling and scrollbars.
Panels are also the cheapest way to draw a solid block - a bar, a rule, a highlight - without a render callback:
local track = win:Panel{ x = 20, y = 90, w = 200, h = 3, back = RGBA(0, 0, 0, 150) }
local fill = win:Panel{ x = 20, y = 90, w = 1, h = 3, back = RGBA(198, 157, 0, 230) }
fill:handle().width = 200 * ratio| Callback | On | Signature |
|---|---|---|
.click |
button | function() end |
.onEnter |
text input |
function() end, fires on Enter |
.render |
window, panel, button |
function(handle) end, every frame after the control draws |
.update |
window |
function(handle) end, every frame regardless of drawing |
win:background(fn) is the wrapper's own hook and receives (wrapper, handle); it
draws behind the controls, whereas .render draws over them.
Assign nil to remove any of them.
An onEnter passed as a field option is wrapped for you and stays silent until
the field satisfies minLen/minBytes. Assigning .onEnter directly on the
control skips that wrapper, so check ui.textOk(input) yourself.
win:handle() (and panel:handle()) gives the underlying object, which is what
you need when a control has to be placed at an exact spot instead of by a layout
group, or looked up later. Windows and panels expose the same set.
Every add* takes explicit x, y, w, h in canvas units, relative to its
parent's top-left corner, and hands back the control it created:
| Method | Returns |
|---|---|
addLabel(id, x, y, w, h) |
a label - set .text on it |
addButton(id, x, y, w, h) |
a button - set .click and its state artwork |
addTextInput(id, x, y, w, h, maxLen) |
a field; maxLen is in characters and is required here |
addImage(id, x, y, w, h) |
an image - call setImage to give it a region |
addScrollPanel(id, x, y, w, h) |
a raw panel, not the win:Panel{...} wrapper |
The rest:
| Method | Does |
|---|---|
getLabel(id) getButton(id) getTextInput(id) getScrollPanel(id)
|
fetch a control back by id |
delControl(id) |
remove one control |
checkHover(x, y, w, h) |
whether the cursor is over that box, in the parent's own coordinates |
renderColor renderText renderImage
|
draw, see Designing a window |
setAttach(v) / getAttach()
|
park any Lua value on the window or panel |
Ids are per parent, and creating a control with an id already in use replaces
the old one - it is destroyed, not returned. That is a silent way to lose a
control, so let the layout group hand out ids (win:nextId() / panel:nextId(),
which is what the group itself uses) and reserve fixed ids for controls another
script has to find.
A panel from addScrollPanel is the bare control: it has setScrollbar,
setContentHeight and the draw calls, but not :Column, :Row or :fit. Use
win:Panel{...} unless you specifically want to place and drive it yourself.
setAttach is a slot for your own data - a row's record, a state table - so that
a script which found a panel by id can recover the context it belongs to without a
lookup table of its own.
Keep the handles the constructors returned. Reach for getLabel/getButton only
when the control was created in a different script:
local w = UI.GetWindow(R.win.exchange)
local lbl = w:getLabel(3)| Call | Returns / does |
|---|---|
UI.NewWindow(id, x, y, w, h) |
a bare window handle; ui.Window builds on it |
UI.GetWindow(id) |
an existing window, from any script |
UI.DelWindow(id) |
destroys a window |
UI.LoadImage(slot, path) |
loads an atlas; false on failure |
UI.Center(w, h) |
two values - the x, y that centre a box of that size |
UI.Scale() |
the player's UI scale factor |
UI.ScreenWidth() / UI.ScreenHeight()
|
the canvas size coordinates are expressed in |
UI.GetTextWidth(text [, fontSize]) |
width of a string in canvas units, so it compares directly with a box; pass the same size you will draw it at |
UI.OpenUrl(url) |
opens a page in the player's browser; refuses anything that is not http or https, and returns whether it went |
ui.ConfirmUrl(url, opts) |
the confirmation box above |
ui.LangText(section, id, fallback) |
a string from CustomText.xml by section name, or the fallback when it is not there |
UI.ToCodePage(s) / UI.FromCodePage(s) / UI.CodePage()
|
encoding, see Text input |
UI.RenderColor / UI.RenderText / UI.RenderImage
|
screen-space draws, inside a render callback only |
UI.GetTickCount() |
milliseconds from a monotonic clock, for animation, see Animating a drawing |
UI.WindowIds() |
the ids of every live window, whether or not a script registered them |
UI.ShowPanel(panel [, show]) / UI.TogglePanel(panel)
|
the game's own panels, see Opening the game's panels |
Log.Add(text) / Log.AddC(color, text)
|
prints a line to the dev debug console (AddC colour from Enums.Color) |
Lang.GetText(type, id) / Lang.TYPE.*
|
localised text from CustomText.bmd, see Localised text
|
ui.Window covers everything UI.NewWindow does and adds layout, skinning and
placement, so reach for the raw call only when you have a reason to.
The command panel and the windows it opens - the ranking board, add stats, the item bank, daily rewards - can be opened from a script, so a menu of your own can sit beside the built-in buttons or replace them.
Every panel on this page belongs to the paid plugin. They are not part of the base client, and a server running without the plugin has none of them, so none of these calls will open anything there.
UI.ShowPanel(UI.PANEL.RANKING) -- open
UI.ShowPanel(UI.PANEL.RANKING, false) -- close
UI.TogglePanel(UI.PANEL.RANKING) -- open if shut, shut if openShowPanel sets the state you name, the same way win:show does for a window of
your own. TogglePanel flips it, which is what the command panel's own buttons do:
wire a menu button to that one and it behaves like the built-in, second press
included.
Reach for ShowPanel when the panel has to end up in a known state whatever it was
before - opening the item bank at the end of an NPC dialog, say. TogglePanel
there would close it for a player who already had it open.
| Panel | Opens | Needs |
|---|---|---|
UI.PANEL.COMMAND |
the command panel itself | the plugin, command panel enabled |
UI.PANEL.RANKING |
ranking board | the above, plus the ranking panel enabled |
UI.PANEL.ADD_STATS |
add stats | the above, plus add stats enabled |
UI.PANEL.ITEM_BANK |
item bank | the above, plus the item bank enabled |
UI.PANEL.DAILY_REWARD |
daily rewards | the above, plus daily rewards enabled |
Opening a panel this way is the same as clicking its button in the command panel: the panel is shown and raised, and it holds whatever the server has already sent it. No extra request goes out.
A panel that is already in the state you ask for is left untouched. Showing an
open panel does not raise it, so a button wired to UI.ShowPanel can be pressed
twice without the window jumping.
Two things have to be true before a panel can open: the server runs the plugin, and its interface configuration switches that particular panel on. The command panel gates the rest, so turning it off takes all four with it.
Where either is missing, the call does nothing and reports nothing. There is no window to open, and no way for a script to tell that apart from a panel that simply stayed shut - which is deliberate, since the answer would say what this server is licensed for.
Write your menu so that costs you nothing. A button that opens a panel this server does not have is a button that does nothing when pressed, which is a layout decision rather than an error to handle:
col:Button("Item Bank", {
onClick = function() UI.ShowPanel(UI.PANEL.ITEM_BANK) end,
})Lang.GetText reads a string out of the client's CustomText.bmd, so a window can
show text in whatever language the player runs:
lbl.text = Lang.GetText(Lang.TYPE.ItemBank, 16)Lang.TYPE.<Section> names a section of the file - by its XML node name - and the
second argument is the entry id. A missing entry comes back as "Missing Text: <id>",
never nil.
Adding your own sections, the fallback helper and encoding the .bmd are on their own
page: Custom text.
| Call | Returns |
|---|---|
Input.MouseX() / Input.MouseY()
|
cursor position, in canvas units |
Input.WheelDelta() |
wheel notches this frame; 0 if it did not move |
Input.KeyPressed(vk) |
true once, on the frame the key goes down |
Input.KeyDown(vk) |
true every frame the key is held |
Input.CheckMousePos(x, y, w, h) |
true when the cursor is inside that box; applies the UI scale to w/h, so it agrees with what is drawn |
Input.KeyboardCaptured() |
true while a Lua text field has focus |
Input.GameState() |
the client's state; compare against Enums.GameState
|
The two sets of constants these calls take:
| Set | How to reach it | Covers |
|---|---|---|
VK |
global, always there |
A-Z, 0-9, F1-F12, N0-N9, and named keys: RETURN ESCAPE SPACE TAB BACK DELETE INSERT HOME END PRIOR NEXT LEFT RIGHT UP DOWN SHIFT CONTROL MENU CAPITAL PAUSE LBUTTON RBUTTON MBUTTON
|
Enums.GameState |
local Enums = require("Enums") |
SELECT_SERVER, SWITCH_CHARACTER, IN_GAME - see Creating windows
|
Key polling already stops while a text field has focus, so a window with an input does not need to guard its hotkeys.
MuLua Scripting Plugin | Home
🖼️ Lua UI
- File map
- Creating windows
- Client and server
- Designing a window
- Protocol
- Scrolling and scrollbars
- Controls
- Custom text
- Text input
- Debug console
- Bitmap Slicer
- Encrypting scripts
Maps
Monsters
Shared
Version 1.14 | 2026-09-09