Repository navigation
LuaUI Windows
A window script is a plain Lua file in UI\Windows\. It creates one window, fills
it with controls, and registers whatever should happen afterwards.
local ui = require("UI")
local R = require("Registry")
local win = ui.Window{
id = R.win.exchange,
center = true,
w = 280, height = 160,
title = "Coin Exchange",
}
if win then
local col = win:Column{ top = 40, padding = 16, spacing = 6 }
col:Label("Nothing here yet.")
endui.Window returns nil if the id is already taken, so guard everything with
if win then. Without that guard a duplicate id turns into an error on the very
first line that touches the window.
Ids are integers, but you never write the integer in a window script. Name it once
in Registry.lua and refer to the name everywhere:
R.win.exchange = 3 -- the floating window
R.win.exchangehud = 4 -- the HUD ticker beside it
R.win.exchangerules = 5 -- the scrolling rules windowPin ids explicitly for anything the server also knows about - the server's
UIProtocol.WINDOW table has to carry the same number. Names left unpinned get an
id automatically, which is fine for a purely local window but drifts the moment
another script is added before it.
Registry.lua builds its two pools with ui.newIdSet(startAt, maxCount, label) -
one for window ids, one for the texture slots used when skinning. Add a pool of
your own the same way if you need a third kind of stable id. ui.idNames(pool)
returns every name a pool has handed out as { name = id }, which is how a script
can enumerate windows it did not create.
The UI is laid out on a fixed 640x480 canvas that the client maps onto the real screen, so coordinates are already resolution independent. Three ways to place a window:
center = true -- middle of the screen
x = 120, y = 80 -- fixed spot on the canvas
anchor = "right", margin = { 6, 0 } -- pinned to an edgeAnchors: topleft top topright left center right bottomleft bottom bottomright.
margin is { x, y }, measured inward from the anchored edge. Prefer an anchor
over fixed coordinates for anything edge-hugging: the rendered size depends on
the player's UI scale while x/y do not, and the anchor accounts for that.
A centred axis ignores its margin. There is nothing to measure inward from when a window is centred on that axis, so the component is dropped:
| Anchor | margin.x |
margin.y |
|---|---|---|
topleft topright bottomleft bottomright
|
works | works |
left right
|
works | ignored - centred vertically |
top bottom
|
ignored - centred horizontally | works |
center |
ignored | ignored |
This is what catches people out when two HUD panels overlap: a panel at right
cannot be nudged up or down by its margin, because right means "vertically
centred on the right edge". Move it to topright or bottomright and the margin
starts working.
Screen space is shared, and the game's own interface has already taken the good spots - the character status and party list at the top left, the minimap at the top right, the skill bar along the bottom. The right edge, vertically centred, is the roomiest area in a default layout, which is exactly why two HUD panels both reaching for it will collide.
The same maths is available on its own, for placing something the option cannot reach - a panel inside a window, or a window you reposition yourself:
local x, y = ui.anchor(w, h, "bottom", 0, 12)It returns the two coordinates for a box of that size at that anchor, with the
margins applied. ui.Window calls it for you when you pass anchor.
| Kind of value | Unit |
|---|---|
x y w h height, margins, padding, spacing |
canvas units - pixels on the fixed 640x480 canvas |
Source rectangles { sx, sy, sw, sh }
|
pixels in the bitmap |
fontSize on text |
screen pixels, not canvas units - boxes scale with the window, letters keep their size |
Colour components in RGBA(r, g, b, a)
|
0-255 each, a 0 = invisible |
maxLen minLen
|
characters |
maxBytes minBytes
|
bytes in the client's codepage |
scrollStep |
canvas units per wheel notch |
Canvas units are not screen pixels. The canvas is a fixed 640x480 space the client
maps onto whatever resolution the player runs, so a window at x = 320 sits in the
same relative place on every machine. What changes with the player's UI scale is
how large it is drawn: the rendered size is w * UI.Scale(), while x/y are not
scaled. That asymmetry is the reason anchor exists.
Height comes in two flavours:
w = 280, height = 380 -- fixed
w = 280, minHeight = 120, maxHeight = 400 -- grows with its content, clampedA content-driven window measures what you laid out and resizes itself on the first frame, then re-centres or re-anchors. Use it for a window whose row count varies. Use a fixed height when the window has a scrolling list inside - there the list should scroll, not the window.
| Option | Values | Default | Configures |
|---|---|---|---|
id |
integer, from Registry.lua
|
required | which window this is |
center |
true / false
|
false |
centre on screen; ignores x/y
|
x y
|
number, canvas units |
0, 0
|
fixed position |
anchor |
topleft top topright left center right bottomleft bottom bottomright
|
none | pin to a screen edge |
margin |
{ x, y }, canvas units |
{ 0, 0 } |
inset from the anchored edge |
w |
number, canvas units | 260 |
width |
height |
number, canvas units | none | fixed height |
minHeight maxHeight
|
number, canvas units | none | grow to fit content, clamped to this range |
padBottom |
number, canvas units | 12 |
gap kept under the last control when fitting |
title |
string | none | text drawn in the header |
titleColor |
RGBA(r, g, b, a), each 0-255 |
RGBA(255, 255, 255, 255) |
title colour |
titleY |
number, canvas units |
7, or centred in header
|
title baseline |
header |
number, canvas units | none | reserved top strip, with a separator drawn under it |
contentTop |
number, canvas units |
header, else 28
|
where scrollable content begins |
drag |
{ x, y, w, h } or true
|
none | grab area; true makes the whole header one; omit and the window cannot be dragged |
skin |
table, see Designing a window | none | frame bitmap |
mode |
"window", "hud"
|
"window" |
render tier and open behaviour |
clickThrough |
true / false
|
false |
let clicks reach the world underneath |
autoOpen |
true / false
|
false |
show the window when the player enters the world |
priority |
number | 0 |
ordering when several windows open together; higher ends up on top |
dim |
number, 0 to 100 | 0 |
blacks out the whole screen behind this window, as a percentage |
dimClickThrough |
true / false
|
false |
whether clicks that miss the window pass through the dim |
scroll |
true / false
|
false |
content taller than the window scrolls |
scrollbar |
true / false
|
false |
give that scroll an interactive bar |
scrollbarWidth |
number, canvas units | 12 |
bar width |
scrollbarMargin |
number, canvas units | 6 |
inset from the frame edge |
scrollbarPad |
{ top, bottom } |
none | inset from the viewport ends; also scrollbarPadTop / scrollbarPadBottom
|
scrollbarAutoHide |
true / false
|
false |
show the bar only while hovering |
scrollbarAlways |
true / false
|
false |
keep track and arrows even without overflow |
contentFullWidth |
true / false
|
false |
let content run under the bar instead of stopping at it |
The scrollbar* group behaves identically on windows and panels; the
scrolling page covers what it looks like.
Note the pairing: a window takes w with height, while panels and controls take
w with h, and everything exposes .width / .height once created. The
controls page has the full table.
Short form, when all you need is a box:
local win = ui.Window(R.win.exchange, ui.CENTER, 260, 160, "Coin Exchange")
local win = ui.Window(R.win.exchange, 120, 80, 260, 160, "Coin Exchange")dim paints the whole screen black behind one window, as a percentage: 0 is off,
100 is solid. It is drawn immediately before that window, so it covers the world
and every window below, and nothing above it.
ui.Window{ id = ..., dim = 45 }
win:setDim(45)By default the dim also eats clicks that miss the window, which is usually what
you want for a dialog. dimClickThrough = true lets them past, so you get the
darkening without blocking anything.
Dimming is not the same as being modal. It changes what the player sees and, unless
you opt out, swallows stray clicks, but it does not stop another window from being
raised over yours. For a real dialog, pair it with setModal.
The two do not stack, they override: a modal window blocks the mouse before the
dim is ever consulted, so dimClickThrough has no effect while setModal(true)
is in force. Pick one. ui.ConfirmUrl does exactly that, going modal only when the
caller has not asked for click-through.
Dropping modality has a price worth stating: a window that is not modal can be
buried. Click the window underneath and it is raised over your dialog, because
nothing holds the dialog on top any more. That is the deal dimClickThrough makes,
so keep it for panels the player may ignore, not for a question you need answered.
A dialog is a window like any other, so it takes drag too. Give it the strip its
title sits in and the player can move it out of the way:
ui.Window{ id = ..., w = 360, height = 148, dim = 45, drag = { 0, 0, 360, 26 } }drag = true would work as well, but it assumes a 28 unit header, so pass the
rectangle when your own title bar is a different height. ui.ConfirmUrl does this:
its box is draggable by the red strip, modal or not.
win:setModal(true) makes a window the only one the mouse reaches: every other
window, including the game's own, stops reacting, and nothing can be raised above it.
setModal(false) releases it.
win:show(true)
win:setModal(true)A modal that is hidden or destroyed releases itself, so a script that forgets to call
setModal(false) cannot lock the interface. Release it anyway - relying on that is a
way to leave the game unclickable for as long as the window lingers.
Mouse movement still reaches the game while a modal is up, so the cursor keeps working normally; only presses are swallowed.
mode decides which tier the window is drawn in, and that changes more than the
draw order.
"window" (default) |
"hud" |
|
|---|---|---|
| Opening | asks the server first | shows immediately |
| ESC | closes the topmost open one | ignores it |
| Z-order | lifts above other windows when clicked | stays under the game's windows |
| Typical use | something the player opens | a permanent readout |
A HUD panel usually wants clickThrough = true so the world underneath stays
clickable. Drop that only if the panel has buttons of its own, or if you gave it a
drag area: clicks that pass through never reach the grab area, so a click-through
panel cannot be moved.
win:show(true) -- request open
win:show(false) -- close, immediate
win:isVisible() -- current state
win:bringToFront()For a floating window show(true) is a request, not a command: the client asks
the server, and the window appears only if the server agrees. Hiding is always
immediate. A HUD window skips the round trip and simply appears.
That is why a window can stay invisible even though your script clearly showed it - the server refused. See Client and server together.
If you need the same window to open in different modes, set openParam before
showing; the value travels with the open request and reaches the server handler.
win:handle().openParam = 2
win:show(true)autoOpen = true shows the window once the player is in the world, so a HUD panel
does not need an Event.on("ingame") of its own:
local hud = ui.Window{ id = R.win.ticker, mode = "hud", autoOpen = true,
priority = 10, clickThrough = true, ... }When several windows come up together, priority decides what ends up on top: they
are raised lowest first, so the highest priority finishes in front. Windows
without a priority keep the old behaviour, whatever was shown or clicked last is on
top.
Priority orders windows within their tier, it does not lift a "hud" panel above a
"window". And because a floating window only appears once the server agrees, a
refused window is simply skipped. If you open several by hand and want the same
ordering applied afterwards:
ui.RaiseByPriority()Event.on("update", function() ... end) -- every frame
Event.on("ingame", function() ... end) -- the player entered the world
Event.on("load", function() ... end) -- scripts finished loadingAn event can carry any number of handlers, so several scripts registering for
update all run. Event.off(name) exists but removes every handler for that
event, including other scripts' - so treat it as a debugging tool, not a way to
unsubscribe. To stop reacting, return early from your own handler instead.
A common opening pattern - show the window when the player enters the world, and also right now if the script was reloaded while already in game:
Event.on("ingame", function() win:show(true) end)
if Input.GameState() == Enums.GameState.IN_GAME then
win:show(true)
endEnums.GameState names the values Input.GameState() returns - the same numbers
the scene event is given. It comes from UI\Enums.lua, so require it like any
other library:
local Enums = require("Enums")The file follows the server's Enums.Group.VALUE shape, but the two are separate
tables in separate runtimes - a group on one side is not automatically on the other:
| Name | Value | Where the player is |
|---|---|---|
Enums.GameState.SELECT_SERVER |
2 | server list |
Enums.GameState.SWITCH_CHARACTER |
5 | character select |
Enums.GameState.IN_GAME |
6 | in the world |
Toggling on a key:
Event.on("update", function()
if Input.KeyPressed(VK.F10) then
win:show(not win:isVisible())
end
end)Input.KeyPressed fires once per press, Input.KeyDown every frame the key is
held. Both stay quiet while a text field has focus, so typing "f10" into a field
does not toggle windows behind it.
Hotkeys are global to the client. Nothing stops two scripts claiming the same
key, and when they do, one press toggles both windows. The shipped scripts already
hold several - F9 reloads Lua in dev mode, F10 is Demo.lua, F11 is
UIPlacement.lua, B is the Item Bank - so pick from what is left and write the
key down where the next person will look.
Text is centred inside the box you give it, so a box that does not match the strip centres against the wrong middle:
h:renderText("TITLE", 0, 6, s.w, 18, ALIGN_CENTER, GOLD, FontM) -- centres at 15
h:renderText("TITLE", 0, 0, s.w, HEADER_H, ALIGN_CENTER, GOLD, FontM) -- correctThe first looks fine until you notice the caption sitting low in its bar. Give the box the strip's own y and height and it lands where the strip's middle is.
ui.Window does not hand you the window. It hands you a wrapper - a small Lua
object that knows how to lay controls out, skin the frame and fit the height. The
window itself lives inside it, and win:handle() is how you reach it.
The distinction matters because the two carry different things:
the wrapper - win
|
the handle - win:handle()
|
|
|---|---|---|
| What it is | convenience layer written in Lua | the window the client actually draws |
| Carries | layout and skinning helpers: Column Row Panel background frameSkin fit show isVisible textureReady titleColor nextId
|
the properties (x, visible, hover, ...) and the low-level calls (addLabel, renderColor, setScroll, ...) |
| Does not have | any property - win.x is nil |
:Column{...} and the other helpers |
bringToFront and setDragArea are the two calls that work on either.
So win:show(true) and win:handle().visible = true do the same thing, and
win.width is nothing while win:handle().width is the width. When an example
reaches for win:handle(), it is because the thing it needs lives on that side.
One consequence worth remembering: UI.GetWindow(id) returns a handle, not a
wrapper. A script fetching another script's window can read .visible and call
addLabel, but not :Column{...} - those helpers belong to the wrapper the other
script is holding.
Panels work the same way: win:Panel{...} gives a wrapper, panel:handle() the
control underneath.
| Call | Does |
|---|---|
win:Column{...} / win:Row{...}
|
layout group that positions controls for you |
win:Panel{...} |
a scrollable sub-area, see the scrolling page |
win:background(fn) |
draw behind the controls, every frame |
win:frameSkin(skin) |
dress the window in a bitmap frame |
win:titleColor(c) |
change the title colour after creation |
win:show(v) / win:isVisible()
|
visibility |
win:bringToFront() |
raise above other floating windows |
win:setModal(v) |
while set, this window is the only one the mouse reaches and nothing can be raised over it |
win:centerOn(parent) |
centre on another window, or on the screen when parent is omitted; never lands off screen |
win:setDim(percent) / win:setDimClickThrough(v)
|
the two dim options, after creation |
win:setDragArea(x,y,w,h) |
change the grab area later |
win:fit(pad) |
re-measure a content-driven window now instead of next frame |
win:nextId() |
the next free control id, for addLabel and friends |
win:textureReady() |
has the skin bitmap finished loading |
win:handle() |
the window underneath, see above |
background, titleColor, setDragArea, show, bringToFront, frameSkin and
fit return the window, so they chain: win:show(true):bringToFront(). The rest
return what you asked for - a group, a panel, the handle, a boolean, an id.
The handle carries the properties:
| Property | Type | Access | What it is |
|---|---|---|---|
id |
integer | read | the id it was created with |
x y width height
|
number, canvas units | read/write | its box |
scale |
number, multiplier | read | the factor the window is rendered at |
depth |
integer | read/write | draw order within its tier; higher draws later |
mode |
"window" / "hud"
|
read/write | render tier; any other string is an error |
clickThrough |
boolean | read/write | whether clicks pass through to the world |
visible |
boolean | read/write | writing true is the open request |
openParam |
integer | read/write | value sent with the open request |
hover dragging
|
boolean | read | current mouse state |
wheel |
integer, notches | read | wheel movement this frame; 0 if it did not move |
scrollOffset scrollMax
|
number, canvas units | read | scroll position and range |
render update
|
function, or nil
|
read/write | per-frame callbacks, see Controls |
Reading another script's window works the same way:
local other = UI.GetWindow(R.win.exchange)
if other and other.visible then ... endMuLua 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