Skip to content

User Interface

Lumina’s screen UI is built on RmlUi, an HTML/CSS-style retained-mode UI library. You author the look as .rml markup and .rcss stylesheet assets, then load, show, and drive them from a script through World.UI. Documents render full-screen over the world’s view.

An RML document is markup with an attached stylesheet. Create one from the Content Browser and edit it in the UI asset editor. Here is a minimal menu.

<rml>
<head>
<title>Lumina Menu</title>
<link type="text/rcss" href="Menu.rcss"/>
</head>
<body>
<div id="panel">
<div class="title">LUMINA</div>
<div id="play" class="button">Play</div>
<div id="toggle" class="button secondary">Toggle Theme</div>
<div id="status" class="status">Click Play to begin.</div>
</div>
</body>
</rml>

Elements are matched from script by their id (#play) or CSS classes, and .rcss styles them with familiar CSS-like properties (color, width, background-color, flexbox layout, transitions). See the RmlUi RCSS reference.

Load a document by its virtual asset path. It starts hidden, call Show to present it. A common pattern is to expose the path as a [Property] so it’s editable.

public sealed class Menu : EntityScript
{
[Property(Tooltip = "RML document shown on screen.")]
public string Document = "/Game/Content/UI/Menu.rml";
private UIDocument _Doc;
public override void OnReady()
{
_Doc = World.UI.LoadDocument(Document);
if (!_Doc.IsValid)
{
Debug.LogError($"Failed to load UI document '{Document}'.");
return;
}
_Doc.Show();
World.UI.EnableCursor(); // free the cursor so buttons are clickable
}
public override void OnDetach()
{
_Doc.Close(); // unload before the world tears down
World.UI.DisableCursor();
}
}
World.UI memberDoes
LoadDocument(path)Loads an .rml asset; returns a UIDocument (hidden until shown).
LoadDocumentFromMemory(rml, sourceUrl)Loads a document from an in-memory RML string.
SetInputMode(mode) / SetCursorMode(mode)Route input and set cursor visibility (below).
EnableCursor() / DisableCursor()Shortcuts for menu-open / menu-close cursor state.
UIDocument memberDoes
Show(modal, autoFocus)Make it visible. modal blocks focus to other documents.
Hide()Hide without unloading (handles + listeners stay valid).
Close()Unload and destroy. Element handles from it become invalid.
BringToFront()Raise above other documents in z-order.
RootThe document’s body element.

For anything dynamic, prefer data binding over poking the DOM by hand. You write a view-model (a C# class whose properties and methods are the data and commands the UI needs), and the RML binds to it declaratively. When a property changes the view updates itself; when the user clicks, a command runs. No element lookups, no manual text/style updates. This is RmlUi’s data binding system, surfaced to C# as ViewModel + World.UI.AddModel.

Derive from ViewModel, mark data with [Bind] and commands with [BindCommand]:

public sealed class MenuModel : ViewModel
{
private string _Status = "Click Play to begin.";
private bool _Dark = true;
[Bind] public string Status { get => _Status; set => Set(ref _Status, value); }
[Bind] public bool Dark { get => _Dark; set => Set(ref _Dark, value); }
// Computed (get-only) = display only. Re-push it with NotifyChanged when its input changes.
[Bind] public string ThemeLabel => _Dark ? "Light theme" : "Dark theme";
[BindCommand] public void Play() => Status = "Starting...";
[BindCommand] public void ToggleTheme() { Dark = !_Dark; NotifyChanged(nameof(ThemeLabel)); }
}

Set(ref field, value) assigns the backing field and, if it changed, pushes the new value to the view. A get-only [Bind] property is display-only; one with a setter is two-way: form controls write back into it.

Mark the element that owns the model with data-model, then bind inside it:

<body data-model="menu">
<div class="panel" data-class-light="!Dark">
<div class="title">LUMINA</div>
<div class="button" data-event-click="Play()">Play</div>
<div class="button" data-event-click="ToggleTheme()">{{ ThemeLabel }}</div>
<div class="status">{{ Status }}</div>
</div>
</body>
BindingEffect
{{ Prop }}Interpolate a value into text.
data-text="Prop"Set the element’s whole text.
data-style-<prop>="expr"Drive a CSS property, e.g. data-style-width="Health + '%'".
data-class-<name>="expr"Add/remove a class while the boolean expression is true.
data-attr-<name>="expr"Drive an attribute.
data-if="expr" / data-visible="expr"Conditionally include / show.
data-value="Prop" / data-checked="Prop"Two-way bind a form control.
data-event-<event>="Cmd(args)"Call a [BindCommand] on the event.
data-for="item : List"Repeat the element per list item (see Lists).

Expressions support arithmetic, comparisons, ! / && / ||, string literals, and the | transform pipe. See the expression reference.

Register the model with AddModel before loading the document that binds to it (RmlUi resolves bindings at load time), and dispose the returned UIDataModel when done.

private MenuModel _Model;
private UIDataModel _Binding;
private UIDocument _Menu;
public override void OnReady()
{
_Model = new MenuModel();
_Binding = World.UI.AddModel("menu", _Model); // BEFORE LoadDocument
_Menu = World.UI.LoadDocument("/Game/Content/UI/Menu.rml");
_Menu.Show();
World.UI.EnableCursor();
}
public override void OnDetach()
{
_Menu.Close();
_Binding?.Dispose(); // removes the model, frees the callback
World.UI.DisableCursor();
}

After that, just set properties like _Model.Status = "Go!"; and the view follows.

A [BindCommand] may take parameters; RML passes them in the call:

[BindCommand] public void ApplyPreset(string preset) { /* ... */ }
<div class="preset" data-event-click="ApplyPreset('high')">High</div>

Expose a list of an item type whose properties are [Bind], then repeat an element over it with data-for:

private sealed class RosterEntry
{
[Bind] public string Name { get; set; }
[Bind] public int Health { get; set; }
}
public sealed class RosterModel : ViewModel
{
private readonly List<RosterEntry> _Members = new() { /* ... */ };
[Bind] public IReadOnlyList<RosterEntry> Members => _Members;
public void Touch() => NotifyChanged(nameof(Members)); // re-push after mutating the list
}
<div class="card" data-for="member : Members">
<div class="name">{{ member.Name }}</div>
<div class="fill" data-style-width="member.Health + '%'"/>
</div>

The list is pushed as a snapshot: mutate the C# list, then call NotifyChanged(nameof(List)) (or Refresh()) to re-push it. List items are display-only.

ViewModel memberDoes
Set(ref field, value)Assign a bound field and push it if it changed.
NotifyChanged(name)Force a re-push of one property (computed values, list changes).
Refresh()Re-push every bound property.
IsBoundTrue once registered and live.
AttributeOnDoes
[Bind]propertyA bound variable: one-way if get-only, two-way if it has a setter; a list if the type is IEnumerable<T> of a type with [Bind] members.
[BindCommand]methodCallable from data-event-*; may take parameters.
World.UI memberDoes
AddModel(name, vm)Register a ViewModel as a data model (before LoadDocument); returns a UIDataModel.
GetModel(name)Re-fetch a registered model (e.g. to push from another script).

Reach an element by id (the indexer or GetElementById) or a CSS selector with Query. The returned UIElement is a lightweight handle, calls on a missing element are safe no-ops.

UIElement Score = _Doc["score"]; // by id, shorthand for GetElementById
UIElement Fill = _Doc.Query(".bar > .fill");
Score.SetText($"Score: {_Points}"); // escaped plain text
Fill.SetStyle("width", "75%"); // inline CSS
Fill.AddClass("full");
UIElement memberDoes
SetText(value) / TextSet plain text (HTML-escaped).
RmlGet/set the element’s inner RML markup (not escaped).
SetStyle(prop, value) / ClearStyle(prop)Set or remove an inline CSS property.
AddClass / RemoveClass / ToggleClass(c, on) / HasClassCSS class control.
GetAttribute(name) / SetAttribute(name, value)Read/write an attribute.
SetVisible(bool) / VisibleShow/hide via CSS display.
Focus() / Blur() / Click()Focus control and synthesizing a click.
Query(selector)First descendant matching a CSS selector.

Subscribe to an RmlUi event on an element with On (or the OnClick shorthand). The handler runs on the game thread. On returns a UIEventSubscription, so dispose it before the world tears down (in OnDetach).

private UIEventSubscription? _PlayClick;
public override void OnReady()
{
// Wire callbacks BEFORE Show so the first click is handled.
_PlayClick = _Doc["play"].OnClick(() => StartGame());
_Doc.Show();
}
public override void OnDetach()
{
_PlayClick?.Dispose();
_Doc.Close();
}

OnClick accepts a no-argument handler or one that takes a UIEvent; On takes an event-type string and a UIEvent handler. Common event types are "click", "mousedown", "mouseup", "mouseover", "mouseout", "mousemove", "keydown", "keyup", "textinput", "change", "submit", "focus", "blur".

The UIEvent passed to a handler.

FieldMeaning
TypeThe UIEventType (e.g. Click, MouseDown, Change).
TargetThe deepest element the event originated on.
CurrentThe element the listener is attached to.
MouseButton0 = left, 1 = right, 2 = middle; -1 if not a mouse event.
MouseX / MouseYCursor position in the document’s layout space.
KeyIdentifierThe RmlUi key id, for key events.
Ctrl / Shift / Alt / MetaModifier keys held with the event.

While a menu is open you usually want a visible cursor and the UI to receive clicks; during gameplay you want the cursor captured for mouselook. World.UI controls both.

World.UI.EnableCursor(); // GameAndUI input + a free, visible cursor
World.UI.DisableCursor(); // Game-only input + a captured (hidden, locked) cursor

For finer control, set the modes directly.

UIInputModeRouting
GameInput drives gameplay only; the UI gets nothing.
UIInput drives the UI only; gameplay input is gated off.
GameAndUIUI gets first refusal, the rest reaches gameplay.
UICursorModeCursor
NormalVisible and free, for menus and pointer UI.
HiddenHidden but free to move.
CapturedHidden and locked to the window, for mouselook.
World.UI.SetInputMode(UIInputMode.UI);
World.UI.SetCursorMode(UICursorMode.Normal);

This script loads a menu, wires two buttons back to C#, and mutates the live DOM in response, the end-to-end shape of a World.UI screen.

using System;
using LuminaSharp;
using Lumina;
namespace Game;
public sealed class MenuExample : EntityScript
{
[Property(Tooltip = "RML document shown on screen.")]
public string Document = "/Game/Content/UI/Menu.rml";
private UIDocument _Menu;
private UIEventSubscription? _PlayClick;
private UIEventSubscription? _ToggleClick;
private int _PlayCount;
private bool _DarkTheme = true;
public override void OnReady()
{
_Menu = World.UI.LoadDocument(Document);
if (!_Menu.IsValid)
{
Debug.LogError($"Failed to load UI document '{Document}'.");
return;
}
_PlayClick = _Menu["play"].OnClick(OnPlay);
_ToggleClick = _Menu["toggle"].OnClick(OnToggleTheme);
_Menu.Show();
World.UI.EnableCursor();
}
private void OnPlay(UIEvent Event)
{
_PlayCount++;
_Menu["status"].SetText($"Play clicked {_PlayCount}x");
}
private void OnToggleTheme(UIEvent Event)
{
_DarkTheme = !_DarkTheme;
_Menu["panel"].SetStyle("background-color", _DarkTheme ? "#1e1e2eff" : "#eff1f5ff");
_Menu["status"].SetText(_DarkTheme ? "Dark theme" : "Light theme");
}
public override void OnDetach()
{
_PlayClick?.Dispose();
_ToggleClick?.Dispose();
_Menu.Close();
World.UI.DisableCursor();
}
}