Skip to content

proposal: client-side UI with RmlUi #21

Description

@PyXiion

Summary

Integrate RmlUi into the client-side Lua runtime defined by #20.

Client packages may include RML, RCSS, fonts, images, and client scripts. Client scripts control the UI through a Lua-facing ui module.

The server has no UI API. It cannot open, close, mutate, inspect, focus, or directly invoke client UI. Server communication uses the networking primitives from #13:

  • net.shared is the recommended source for reactive server-owned UI state.
  • net.channel is used for transient UI actions, commands, and events.
  • Local ref values are used for client-only UI state.

Related proposals:

Scope

The UI module is available only inside an active client package with the client.ui capability:

local ui = require "ui"

RML and RCSS remain package resources. Lua controls documents, elements, models, and events, while the client integration owns RmlUi initialization, input forwarding, updating, and rendering.

Contexts

A context is an independent UI surface:

local context = ui.context("main", {
  input = "gameplay",
})

Supported input modes should include:

gameplay
ui
passthrough

A package may have multiple contexts, but each context belongs to its package runtime.

Lua does not manually call RmlUi's update or render loop.

Only the active package revision may own input or focus.

Documents

local document = context:load("assets/shop.rml")

document:show()
document:hide()
document:close()

document:isVisible() -> boolean
document:get("buy") -> Element | nil
document:focus()

Convenience form:

local document = ui.open("assets/shop.rml", {
  context = "main",
  input = "ui",
})

Resources are package-relative. A package may not access another package's resources by default.

Documents and element handles become invalid after close. Invalid handles must fail clearly rather than silently affecting another document.

Elements

local buy = document:get("buy")

buy:setText("Buy")
buy:addClass("enabled")
buy:removeClass("disabled")

buy:setAttribute("data-item", "apple")
buy:setProperty("color", "#ffffff")

buy:show()
buy:hide()

Lookup:

document:find("#buy") -> Element | nil
document:findAll(".slot") -> iterator

The first version should expose common element operations rather than mirror the entire native RmlUi API.

Events

local disconnect = buy:on("click", function(event)
  print(event.type)
end)

disconnect()

Suggested event fields:

event.type
event.target
event.currentTarget
event.value
event.mouse
event.keyboard

Suggested event methods:

event:preventDefault()
event:stopPropagation()

Callbacks should follow the event semantics from #6, including cleanup, one-shot handlers, priorities, and package event-group ownership.

Initial event names:

click
mousedown
mouseup
mousemove
focus
blur
change
submit
keydown
keyup
textinput
dragstart
drag
dragend

The UI should not invoke Lua once per render frame unless the package explicitly subscribes to the client tick event.

Reactive Models

A UI model adapts Lua reactive values to RmlUi data binding. It must not replace ref from #13.

Local model:

local ref = require "ref"

local selected = ref.new(nil)
local model = ui.model("shop", {
  selected = selected,
})

RML:

<body data-model="shop">
  <p>{{selected}}</p>
  <button id="buy">Buy</button>
</body>

Models should support:

model:get("selected") -> value
model:set("selected", "apple")
model:update("quantity", function(value)
  return value + 1
end)

model:watch("selected", function(value, previous)
end)

model:close()

Model fields follow ref semantics:

  • Changes are explicit.
  • Nested table mutation is not tracked automatically.
  • Watchers run after changes.
  • Watchers return cleanup functions.
  • Cleanup occurs when the model or package closes.

Recommended Network State Pattern

net.shared is the recommended API for server-owned reactive state:

local net = require "net"
local ui = require "ui"

local state = net.shared("shops:state")
local model = ui.model("shop", state)

local document = ui.open("assets/shop.rml")

The client waits for the shared value to become ready and reflects subsequent changes into the RmlUi data model.

The UI does not need to manually copy each update:

state:ready(function(shared)
  -- The model is now ready to render initial state.
end)

The server owns the shared value. The client only renders it. Writable shared state remains subject to the permissions and untrusted-input rules from #13.

UI Actions and Events

Transient user actions use net.channel:

local actions = net.channel("shops:actions")

model:set("selected", "apple")

document:get("buy"):on("click", function()
  actions:send({
    kind = "buy",
    item = model:get("selected"),
  })
end)

The server receives ordinary untrusted data and validates it. The UI layer does not create an RPC system or infer server methods from element IDs.

Server-to-client transient events may also use channels:

local events = net.channel("shops:events")

events.handler = function(message)
  if message.kind == "notification" then
    model:set("error", message.text)
  end
end

Because channel messages are transient, a UI that needs recoverable state should use net.shared or define an explicit snapshot request protocol over a channel.

Input and Focus

Default UI behavior should not block gameplay:

ui.open("assets/hud.rml", {
  input = "passthrough",
})

Menus can explicitly capture input:

ui.open("assets/menu.rml", {
  input = "ui",
})

When a document, context, package, or package revision closes:

  • Focus is released.
  • Input capture is released.
  • Event handlers are removed.
  • A previous eligible context may regain focus.
  • Stale callbacks cannot receive input.

Hot Reload

UI follows the client package revision lifecycle from #20:

  1. The new package revision downloads and verifies.
  2. A new client runtime is initialized.
  3. New UI contexts and documents are created.
  4. The new runtime becomes active.
  5. The old revision loses input ownership.
  6. Old documents and contexts close.
  7. The old runtime stops.

If UI initialization fails, the old UI remains active.

The new runtime reacquires fresh net.shared and net.channel handles. Shared state can become ready again; transient channel messages are not replayed automatically.

Resources

Supported package-owned resources include:

  • RML documents.
  • RCSS stylesheets.
  • Images.
  • Fonts.
  • Templates.
  • Localization files.

Embedded scripting inside RML is not supported. Client Lua scripts are the only script execution mechanism.

The UI must not allow arbitrary filesystem access or unrestricted cross-package resource loading.

Capabilities

The UI requires:

client
client.ui

Network permissions are separate:

network.channel.send
network.channel.receive

Access checks remain active in event callbacks, watchers, timers, coroutines, and async continuations.

Security and Authority

The UI is presentation code, not an authority boundary:

  • Form fields are client-controlled.
  • Button clicks are client-controlled.
  • UI models are client-controlled.
  • Client channel messages are untrusted.
  • The server validates all gameplay actions.

The server may send state and events through the existing networking API, but it never directly controls client documents or elements.

This proposal does not claim to protect a player from a malicious server that deliberately sends malicious client code.

Non-Goals

  • Server-side UI management.
  • Server-driven document mutation.
  • Automatic RPC from buttons or forms.
  • Client-to-client UI communication.
  • Exposing every RmlUi native API through Lua.
  • Arbitrary filesystem access.
  • Embedded RML scripting.
  • Automatic deep Lua table mutation tracking.
  • Treating UI state as authoritative.

Acceptance Criteria

  • Client packages can load RML and RCSS resources.
  • UI is available only with client.ui.
  • UI lifecycle follows the client package lifecycle from proposal: client-side Lua scripts and package runtime #20.
  • UI hot reload is atomic and rollback-safe.
  • Only the active package revision owns input.
  • UI callbacks retain package capability context.
  • net.shared is the recommended API for reactive server-owned UI state.
  • net.channel is available for transient UI actions and events.
  • The server cannot directly manipulate client documents or elements.
  • Local ref values can participate in UI models.
  • UI cleanup is deterministic on package stop, disconnect, and reload.

Examples

Reactive Server State and UI Actions

local net = require "net"
local ui = require "ui"

local state = net.shared("shops:state")
local actions = net.channel("shops:actions")

local model = ui.model("shop", state)
local document = ui.open("assets/shop.rml", {
  input = "ui",
})

document:get("buy"):on("click", function()
  actions:send({
    kind = "buy",
    item = model:get("selected"),
  })
end)

The server owns shops:state; the client renders it. The server validates the buy message received through shops:actions.

RML Data Binding

assets/shop.rml:

<rml>
  <head>
    <link type="text/rcss" href="shop.rcss" />
  </head>
  <body data-model="shop">
    <h1>{{title}}</h1>
    <p>{{description}}</p>
    <button id="buy">Buy</button>
  </body>
</rml>

The model updates when the shared value changes; the script does not need to manually rewrite the document text.

Local UI State

local ref = require "ref"
local ui = require "ui"

local selected = ref.new(1)
local model = ui.model("menu", {
  selected = selected,
})

model:watch("selected", function(value, previous)
  print("Selection:", previous, "->", value)
end)

Local UI state is separate from server-owned state and is not authoritative.

Server-to-Client Events

local net = require "net"
local ui = require "ui"

local events = net.channel("shops:events")
local model = ui.model("shop", { error = nil })

events.handler = function(message)
  if message.kind == "notification" then
    model:set("error", message.text)
  end
end

Channels are transient. A UI that needs recoverable initial state should use net.shared or request a snapshot through a channel.

HUD Without Input Capture

local hud = ui.open("assets/hud.rml", {
  input = "passthrough",
})

The HUD renders while gameplay continues to receive input.

Cleanup

local context = ui.context("menu", { input = "ui" })
local document = context:load("assets/menu.rml")

local disconnect = document:get("close"):on("click", function()
  context:close()
end)

Closing the context removes its documents, event handlers, focus, and input capture. Package reload performs the same cleanup automatically.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions