Repository navigation
LuaUI Protocol
Two channels carry Lua messages. They use the same payload format and differ only in what addresses a message.
| Channel | Addressed by | Use it for |
|---|---|---|
| window | window id + action | anything belonging to a window |
| general | opcode | systems with no window - HUD feeds, counters, notifications |
Client UI\Protocol.lua and server Defines\UIProtocol.lua hold one table per
system: its actions, its result codes, its limits. Both files must carry identical
values.
Keep a system's constants inside its own table. Two windows can then never disagree
about what 2 means, and you can read a handler without hunting for a global.
P.Exchange = {
id = R.win.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,
},
}Every answer carries a result byte. Zero always means success, on both channels
and in every system - an open request is granted only on 0.
Beyond that the numbering is yours. A convention that pays off once a system grows: group codes by what the player can do about them.
RESULT = {
OK = 0,
DISABLED = 10, -- feature is off; nothing the player can do
NOT_READY = 11,
BUSY = 20, -- try again in a moment
IN_TRADE = 21,
NO_FUNDS = 30, -- the player can fix this
BAD_AMOUNT = 31,
}Grouping by remedy rather than by internal cause means the client can map a whole band to one message without a case for every code, and adding a code later does not break an older client.
Both sides use the same primitives. All integers are unsigned and little-endian.
| Type | Bytes | Notes |
|---|---|---|
byte |
1 | 0 to 255 |
word |
2 | 0 to 65535 |
dword |
4 | 0 to 4294967295 |
qword |
8 | exact up to 2^53 - see below |
float |
4 | ~7 significant digits |
double |
8 | full precision - the safe choice for a real number |
text |
2 + n | length-prefixed, any bytes |
str |
n | fixed width, zero padded |
Two traps worth stating plainly:
-
floatis not precise enough for money.1500000123comes back as1500000128. Usedword/qwordfor whole numbers anddoublefor real ones. -
qwordis exact only to 2^53. The client runs LuaJIT (Lua 5.1), where every number is a double, so the server's own 64-bit integers survive only as far as the wire. That range still covers experience, zen and tick counts. Item serials use all 64 bits - send those as twodwords or astext.
Client, outgoing:
net.send(S.id, S.CONVERT)
:dword(amount)
:text(note)
:send()The packet sizes itself from what you appended; :send() finishes it.
Client, incoming:
net.on(S.id, S.RATE, function(result, reader, iParam, windowId)
local rate = reader:dword()
end)Server, incoming and outgoing:
UIWindow.On(S.id, S.CONVERT, function(oPlayer, reader, iParam)
local amount = reader:dword()
local w = UIPacket.Writer()
w:dword(newBalance)
UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.OK, w)
end)Reader methods on both sides: byte word dword qword float double text str(len),
plus remaining() and, on the client, seek(pos).
A short read returns 0 or "" rather than raising, so a truncated or forged
packet cannot take a handler down. A reader handing back zeros usually means the
two sides disagree about the field order - on the client the console says so, with
the position and the size it wanted.
Payloads are optional. Send a bare result when there is nothing to report:
UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.NO_FUNDS)and check before reading when the answer may be empty:
net.on(S.id, S.CONVERT, function(result, reader)
if reader:remaining() >= 4 then
local balance = reader:dword()
end
end)For anything without a window of its own, addressed by an opcode instead of a window id.
Opcodes are global to the client. Unlike an action, which is scoped to its
window, one opcode means one thing across every script on the machine - two
systems that both pick 100 will read each other's packets. So put the number in
the system's protocol table with the rest of its constants, where the next person
can see it is taken:
P.Exchange = {
...
TICKER_OP = 110, -- general channel: rate + today's allowance
}The shipped scripts take 100 and 101 (the demo), 110 (the Coin Exchange
ticker) and 120 (windows opened from an NPC).
The running example's ticker is exactly this case: it needs the current rate, but it is a HUD panel with no window channel of its own.
Client - ask once on login, then take pushes as they come:
Net.OnPacket(S.TICKER_OP, function(reader)
Exchange.rate = reader:dword()
Exchange.usedToday = reader:dword()
Exchange.dailyLimit = reader:dword()
end)
Event.on("ingame", function()
Net.CreatePacket(S.TICKER_OP):send()
end)Server - the same constant, read from the mirrored table:
function Exchange.SendTicker(oPlayer)
local w = UIPacket.Writer()
w:dword(Exchange.rate)
w:dword(Exchange.UsedToday(oPlayer))
w:dword(Exchange.DAILY_LIMIT)
Net.Send(oPlayer, S.TICKER_OP, w)
end
Net.On(S.TICKER_OP, function(oPlayer, reader)
Exchange.SendTicker(oPlayer)
end)Nothing stops the server calling Exchange.SendTicker on its own - after a
conversion, or for every player when an admin changes the rate. A push does not
have to answer a request.
Note the capitalisation: Net.CreatePacket / Net.OnPacket are the client's own
global table, while net.send / net.on (lowercase local from require("Net"))
are the window channel. Mixing them up sends a window message to nobody.
Under net.send and net.on sit two globals:
Net.WindowUse(windowId, iParam) -- returns a writer, same as net.send
Net.OnWindowUse(fn) -- installs THE single incoming dispatcherNet.WindowUse is harmless - net.send is literally a call to it.
Do not call Net.OnWindowUse from a window script. There is one dispatcher for
the whole client and UI\Net.lua owns it; installing your own replaces it and
every other window stops receiving answers. Register with net.on instead, which
routes through that one dispatcher.
net.off(windowId [, action]) removes a handler again - omit the action to drop
the window's fallback.
Catalogue data - names, prices, colours, capacities - should live in a file on each
side rather than being sent on every open. Ship the client a .bmd and the server
an .xml describing the same catalogue, and let the channel carry only what
changes: the player's own state.
That keeps the packets small, survives a player opening a window twenty times, and means a price change is a file edit rather than a protocol change.
text and str carry bytes exactly as given. The channel does not know whether a
field is a name, a token or a serial, so it does not convert anything.
Scripts and the renderer work in UTF-8. Where a value has to match the game's own structures or a database column, convert it at that point:
input.textCodePage -- the field's text in the client's codepage
UI.ToCodePage(s) -- any string; returns the text and a lossy flag
UI.FromCodePage(s) -- back to UTF-8, for text that came from the game or DB
UI.CodePage() -- the codepage number in forceConverting everything "just in case" is the wrong default - it corrupts anything that was never text in the first place.
- Client and server together
- Text input - where the encoding rules bite hardest
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