Repository navigation
LuaUI Server
The example below is the Coin Exchange window - WCoin converted to Ruud at a fixed rate - built on both sides. Other pages develop the same system: its HUD ticker, its history list and its voucher field.
A HUD panel that only displays local information needs no server half at all.
When your script shows a floating window, the client asks the server for
permission. The server's handler returns 0 to allow it and anything else to
refuse; on a refusal the window simply never appears.
That gate is where server-side conditions belong - level requirements, a map restriction, an event window, a plugin switched off. Checking them on the client only hides the button; checking them here actually enforces it.
If no handler is registered for a window id, the open is allowed. That is deliberate, so a purely decorative window needs no server code.
The gate is also where you stop a second window from landing on top of one the
player already has open. oPlayer:GetIfStateType() reads their interface state -
Enums.IfState.NONE when nothing is open, or a value like TRADE, SHOP,
WAREHOUSE or an NPC dialog when a native interface holds them.
UIWindow.OnOpen(S.id, function(oPlayer) -- S = your window's protocol table
if oPlayer:GetIfStateType() ~= Enums.IfState.NONE then
return 1 -- refuse: a trade, shop, warehouse, NPC... is already open
end
oPlayer:SetIfState(1, 0, Enums.IfState.LUAWINDOW) -- mark the player busy
return 0
end)Reading the state refuses the open while another interface is up. Setting it to
LUAWINDOW does the reverse - the native systems now treat the player as busy and
will not start a trade or a shop over your window, and a second Lua window with the
same guard is refused.
The engine clears it on close. Hiding a window the server let open - ESC, or the
script hiding it (visible = false, its X button) - sends the game's ordinary
close-window packet on the next frame. The server calls
onCloseWindow, and when that returns 0, as shipped, it
ends whatever interface is open and resets the state to NONE. A hud window never
asked to open, so it never reports a close either.
The packet names no window - the game's own NPC windows send the same one - so
onCloseWindow cannot tell which window closed. A second window without the guard
clears the first one's state when it closes. A window removed while open
(UI.DelWindow or a script reload), or hidden before the server's answer arrives,
sends nothing, which leaves the player busy until the next close or a relog.
A window that needs its own cleanup sends a close action from the client. ESC hides a
window without calling its script, so send it from an Event.on("update") handler
watching win:isVisible(), not only from the X button:
UIWindow.On(S.id, S.CLOSE, function(oPlayer) -- S.CLOSE: a use action you define
-- whatever the window keeps for the player
end)Leaving IfState alone is not neutral: the close packet ends whatever interface the
player has open, so closing any window but a hud cancels a trade and ends a shop or
warehouse. A panel that must sit beside those should be a hud; any other window is
safer with the full guard. If you want the check but not the block, keep the guard and
drop the SetIfState call - reading GetIfStateType on open costs nothing and can
never leave a player stuck busy, but a trade or shop started after the open still ends
when the window closes. The full type list is in
Player structure.
Both sides need the same numbers. Client, in UI\Protocol.lua:
P.Exchange = {
id = R.win.exchange,
RATE = 1, -- S->C rate and balances
CONVERT = 2, -- C->S dword amount
HISTORY = 3, -- S->C past conversions
MIN = 100,
MAX = 100000,
TICKER_OP = 110, -- general channel: rate + today's allowance
RESULT = {
OK = 0,
BAD_AMOUNT = 10,
NO_FUNDS = 11,
LIMIT = 12,
},
}Server, in Defines\UIProtocol.lua, the same values:
UIProtocol.WINDOW = {
DEMO = 1,
EXCHANGE = 3,
}
UIProtocol.Exchange = {
id = UIProtocol.WINDOW.EXCHANGE,
RATE = 1,
CONVERT = 2,
HISTORY = 3,
MIN = 100,
MAX = 100000,
TICKER_OP = 110, -- general channel: rate + today's allowance
RESULT = {
OK = 0,
BAD_AMOUNT = 10,
NO_FUNDS = 11,
LIMIT = 12,
},
}Change one, change the other, in the same sitting.
Data\Plugins\LuaAPI\Windows\CoinExchange.lua. The rate is fixed in the script;
the daily allowance and the history are per character and kept in the database.
local S = UIProtocol.Exchange
-- query ids are named once in Defines\Enums.lua (Enums.QueryDS)
local Q_LOAD_DAILY = Enums.QueryDS.EXCHANGE_LOAD_DAILY
local Q_LOAD_HISTORY = Enums.QueryDS.EXCHANGE_LOAD_HISTORY
local Q_SAVE = Enums.QueryDS.EXCHANGE_SAVE
-- The system's own state, referred to by this name from the other scripts.
Exchange = {
rate = 10, -- 10 WCoin -> 1 Ruud
DAILY_LIMIT = 100000, -- WCoin one character may convert per day
used = {}, -- [CharacterId] = { day, wcoin }, filled by the load
history = {}, -- [CharacterId] = { { date, spent, gained }, ... }
}
local function CharId(oPlayer)
return oPlayer.userData and oPlayer.userData.CharacterId or 0
end
-- Today's total, from the cache the load fills. The cache carries its day, so it
-- reads as 0 after midnight without a separate rollover.
function Exchange.UsedToday(oPlayer)
local rec = Exchange.used[CharId(oPlayer)]
if rec == nil or rec.day ~= os.date("%Y-%m-%d") then return 0 end
return rec.wcoin
end
local function sendRates(oPlayer)
local w = UIPacket.Writer()
w:dword(Exchange.rate)
w:dword(Coin.Get(oPlayer, Enums.CoinType.WCOIN))
w:dword(Coin.Get(oPlayer, Enums.CoinType.RUUD))
UIWindow.Send(oPlayer, S.id, S.RATE, S.RESULT.OK, w)
endsendHistory and Exchange.SendTicker, called below, are built on the
scrolling and protocol pages; they send the
history list and the HUD figure.
DB.QueryDS is asynchronous - it returns nothing, and the result arrives later in
onDSDBQueryReceive. So the window opens with whatever is cached and refreshes when
the DataServer answers.
CoinExchangeDB = {} -- global, so the callback in CallbacksDB.lua can reach it
local pendDaily = {} -- [playerIndex] = { charId, value }
local pendHist = {} -- [playerIndex] = { charId, rows }
local function RequestLoad(oPlayer)
local charId = CharId(oPlayer)
if charId <= 0 then return end
pendDaily[oPlayer.Index] = { charId = charId, value = 0 }
pendHist[oPlayer.Index] = { charId = charId, rows = {} }
DB.QueryDS(oPlayer.Index, Q_LOAD_DAILY,
string.format("EXEC IGC_CoinExchange_LoadDaily %d", charId))
DB.QueryDS(oPlayer.Index, Q_LOAD_HISTORY,
string.format("EXEC IGC_CoinExchange_LoadHistory %d, 20", charId))
end
UIWindow.OnOpen(S.id, function(oPlayer)
RequestLoad(oPlayer)
sendRates(oPlayer)
sendHistory(oPlayer)
return 0
end)Results arrive one column per row. A second packet is a separate async task and can
overtake the first, so each value goes into its own (packet, row) bucket and the
order stops mattering.
function CoinExchangeDB.OnRow(iPlayerIndex, iQuery, iPacket, iRow, oRow)
if iQuery == Q_LOAD_DAILY then
local p = pendDaily[iPlayerIndex]
if p and oRow:GetColumnName() == "UsedWCoin" then
p.value = tonumber(oRow:GetValue()) or 0
end
elseif iQuery == Q_LOAD_HISTORY then
local p = pendHist[iPlayerIndex]
if p then
local key = iPacket * 1000 + iRow
p.rows[key] = p.rows[key] or {}
p.rows[key][oRow:GetColumnName()] = oRow:GetValue()
end
end
end
function CoinExchangeDB.OnComplete(iPlayerIndex, iQuery)
local oPlayer = Player.GetObjByIndex(iPlayerIndex)
if iQuery == Q_LOAD_DAILY then
local p = pendDaily[iPlayerIndex]
pendDaily[iPlayerIndex] = nil
if p == nil then return end
Exchange.used[p.charId] = { day = os.date("%Y-%m-%d"), wcoin = p.value }
if oPlayer and CharId(oPlayer) == p.charId then Exchange.SendTicker(oPlayer) end
elseif iQuery == Q_LOAD_HISTORY then
local p = pendHist[iPlayerIndex]
pendHist[iPlayerIndex] = nil
if p == nil then return end
local keys = {}
for k in pairs(p.rows) do keys[#keys + 1] = k end
table.sort(keys) -- SQL returns newest first; the key preserves that order
local list = {}
for _, k in ipairs(keys) do
local row = p.rows[k]
list[#list + 1] = {
date = row.ConvertLabel or "",
spent = tonumber(row.SpentWCoin) or 0,
gained = tonumber(row.GainedRuud) or 0,
}
end
Exchange.history[p.charId] = list
if oPlayer and CharId(oPlayer) == p.charId then sendHistory(oPlayer) end
end
endRoute those results in CallbacksDB.lua, by query id:
function onDSDBQueryReceive(iPlayerIndex, iQueryNumber, bIsLastPacket, iCurrentRow, btColumnCount, btCurrentPacket, oRow)
local Q = Enums.QueryDS
if oRow ~= nil and (iQueryNumber == Q.EXCHANGE_LOAD_DAILY or iQueryNumber == Q.EXCHANGE_LOAD_HISTORY) then
CoinExchangeDB.OnRow(iPlayerIndex, iQueryNumber, btCurrentPacket, iCurrentRow, oRow)
end
if (bIsLastPacket == 1 or bIsLastPacket == true) and
(iQueryNumber == Q.EXCHANGE_LOAD_DAILY or iQueryNumber == Q.EXCHANGE_LOAD_HISTORY) then
CoinExchangeDB.OnComplete(iPlayerIndex, iQueryNumber)
end
endA character who has converted nothing today still gets an answer: an empty result
arrives as one callback with oRow == nil and the last-packet flag set, so
OnComplete runs and the cache settles at 0.
UIWindow.On(S.id, S.CONVERT, function(oPlayer, reader)
local amount = reader:dword()
if amount < S.MIN or amount > S.MAX or amount % Exchange.rate ~= 0 then
UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.BAD_AMOUNT)
return
end
if Exchange.UsedToday(oPlayer) + amount > Exchange.DAILY_LIMIT then
UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.LIMIT)
return
end
if Coin.Charge(oPlayer, Enums.CoinType.WCOIN, amount) == false then
UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.NO_FUNDS)
return
end
local gained = amount // Exchange.rate
Coin.Add(oPlayer, Enums.CoinType.RUUD, gained)
-- bump the cached daily counter, keyed to today so midnight resets it
local charId = CharId(oPlayer)
local today = os.date("%Y-%m-%d")
local rec = Exchange.used[charId]
if rec == nil or rec.day ~= today then
rec = { day = today, wcoin = 0 }
Exchange.used[charId] = rec
end
rec.wcoin = rec.wcoin + amount
-- mirror the new row into the cache so the window updates without a reload;
-- the same row is what the procedure writes to the database
local list = Exchange.history[charId] or {}
Exchange.history[charId] = list
table.insert(list, 1, { date = os.date("%m-%d %H:%M"), spent = amount, gained = gained })
-- persist: one procedure bumps the counter and appends the history row
DB.QueryDS(oPlayer.Index, Q_SAVE,
string.format("EXEC IGC_CoinExchange_AddConversion %d, %d, %d", charId, amount, gained))
UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.OK)
sendRates(oPlayer)
sendHistory(oPlayer)
Exchange.SendTicker(oPlayer)
end)Do not re-read the database right after the write to refresh the window: the write is asynchronous too, and a load fired straight after it can overtake it and read the old rows. Updating the cache in place, as above, avoids that race.
Register the file in Main.lua:
LoadScript(BASE .. "Windows\\CoinExchange.lua")The IGC_CoinExchange_* procedures and their tables are created by
SQL Scripts\CoinExchange.sql, shipped with the server files - run it once against
the game database. This page does not reproduce the SQL.
Rules the handler follows:
-
Charge before you grant.
Coin.Chargededucts only when the balance covers it and reportsfalseotherwise, so the failure path costs the player nothing. - Validate the amount even though the field is numeric. The field limits what a player can type; it does not limit what can arrive.
-
Only ints reach the query.
charId,amountandgainedare numbers, built with%d; never interpolate player text into SQL. - Re-send the state after a change. The client should never have to guess the new balance from what it asked for.
// is integer division and exists in the server's Lua 5.3. Plain / yields a
float, which is not what a function expecting a whole amount should be handed.
Anything that creates or deletes items has one more obligation:
Player.CheckItemAction(oPlayer) must return
Enums.ItemActionBlock.OK first. Currency is not affected by it.
Data\Custom\Scripts\UI\Windows\Demo\CoinExchange.lua:
local ui = require("UI")
local R = require("Registry")
local net = require("Net")
local P = require("Protocol")
local S = P.Exchange
-- This side's half of the system state, global so the HUD ticker script can read
-- it too. Written `X or {...}` because either script may load first; the server
-- keeps its own table under the same name.
Exchange = Exchange or { rate = 0, usedToday = 0, dailyLimit = 0, history = {} }
local win = ui.Window{
id = R.win.exchange,
center = true,
w = 280, height = 380,
header = 34,
title = "Coin Exchange",
drag = { 0, 0, 280, 34 },
}
if win then
local col = win:Column{ top = 42, padding = 16, spacing = 6, align = "center" }
local lRate = col:Label("rate: -", { slide = false })
local lBalance = col:Label("balance: -", { slide = false })
local input = col:TextInput{
w = 180, h = 20,
numeric = true, maxValue = S.MAX,
placeholder = "WCoin to convert",
}
local status = col:Label("", { color = RGBA(255, 120, 120, 255), slide = false })
col:Button("Convert", {
w = 120, h = 26,
onClick = function()
local amount = tonumber(input.text) or 0
status.text = ""
net.send(S.id, S.CONVERT):dword(amount):send()
end,
})
net.on(S.id, S.RATE, function(result, reader)
Exchange.rate = reader:dword()
local wcoin, ruud = reader:dword(), reader:dword()
lRate.text = ("%d WCoin = 1 Ruud"):format(Exchange.rate)
lBalance.text = ("you have %d WCoin, %d Ruud"):format(wcoin, ruud)
end)
net.on(S.id, S.CONVERT, function(result)
if result == S.RESULT.OK then
input.text = ""
status.text = ""
elseif result == S.RESULT.NO_FUNDS then
status.text = "Not enough WCoin."
elseif result == S.RESULT.LIMIT then
status.text = "Daily limit reached."
else
status.text = "Enter a multiple of the rate."
end
end)
Event.on("update", function()
if Input.KeyPressed(VK.F10) then
win:show(not win:isVisible())
end
end)
endThe window is deliberately taller than its controls need: the scrolling page fills the space below them with the conversion history, and the design page dresses the same 280x380 frame in the game's artwork.
Outgoing, from the client:
net.send(windowId, action):dword(v):text(s):send()Incoming, on the client:
net.on(windowId, action, function(result, reader, iParam, windowId) end)
net.on(windowId, function(result, reader, iParam) end) -- fallback for the windowOn the server:
UIWindow.On(id, action, function(oPlayer, reader, iParam) end)
UIWindow.On(id, function(oPlayer, reader, iParam) end) -- fallback
UIWindow.Send(oPlayer, id, action, result, writer) -- writer optionalThe routing key is always the pair (window id, action). A message with no handler is dropped quietly, so an older client ignores an action it does not know rather than erroring.
The server's own entry points live in CallbacksUI.lua and forward to
UIWindow.DispatchOpen, UIWindow.DispatchUse and Net.Dispatch. Those three are
plumbing between the entry points and the routing tables - nothing in a window
script should call them.
An error inside a handler is caught and written to the server log; it does not disconnect the player or stop other windows. The window will simply never answer, which from the player's side looks like a button that does nothing - so check the log first when a window goes quiet.
Talking to an NPC can open a window, or one of the client's own panels, instead of
the NPC's dialog. The server holds the binding, in Includes\NpcWindow.lua:
NpcWindow.Bind({ class = 2497, window = UIProtocol.WINDOW.DEMO, only = true })
NpcWindow.Bind({ class = 2495, map = 0, panel = UIProtocol.PANEL.RANKING })| Field | Meaning |
|---|---|
class |
the NPC's class |
map |
optional - binds only the NPC on that map |
window |
a window id from UIProtocol.WINDOW
|
panel |
instead of window, one of the client's panels from UIProtocol.PANEL - the values of the client's UI.PANEL
|
only |
true - the window opens, and takes actions, only for a player who talked to that NPC and stands within 5 tiles of it. It holds for the window wherever it opens from, keys and buttons included |
Put the line in the window's own server script. The NPC is an ordinary
MonsterSpawn.xml entry whose class is marked IsNpc="1" - see
A talking NPC.
The talk reaches onNpcTalk, which returns 1 for a bound
NPC, so its own dialog stays shut. The server sends general-channel opcode 120
naming the window, and the client's Windows\NpcWindow.lua shows it the ordinary way:
it asks the server, and the open gate still decides. A panel is
the client's own and opens at once. A hud window is never opened this way, since it
shows without asking.
only is checked on every open and every action, so walking away with the window up
and pressing a button does nothing. Without only the NPC is one more way to open a
window that also opens from a key or a button.
Do not bind an NPC that should open one of the game's own windows - a shop, a
warehouse or a chaos machine. Those come from NpcType and ShopList.xml, and a
binding takes the NPC over.
- Protocol - payload types and result-code conventions
- A talking NPC - making a custom id an NPC on the server
-
Global Functions -
Coin,Player.CheckItemAction,Inventory
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