RmlUi
RmlUi is a UI framework that is defined using a HTML/CSS style workflow (using Lua instead of JS) intended to simplify UI development especially for those already familiar with web development. It is designed for interactive applications, and so is reactive by default. You can learn more about it on the RmlUI website and differences in the Recoil version here.
How does RmlUI Work?
To get started, it’s important to learn a few key concepts.
- Context: This is a bundle of documents and data models.
- Document: This is a document tree/DOM (document object model) holding the actual UI.
- RML: This is the markup language used to define the document, it is very similar to XHTML (HTML but must be well-formed XML).
- RCSS: This is the styling language used to style the document, it is very similar to CSS2 but does differ in some places.
- Data Model: This holds information (a Lua table) that is used in the UI code in data bindings.
On the Lua side, you are creating 1 or more contexts and adding data models and documents to them.
On the Rml side, you are creating documents to be loaded by the Lua which will likely include data bindings to show information from the data model, and references to lua functions for event handlers.
As each widget/component you create will likely comprise of several files (.lua, .rml & .rcss) you may find having a folder for each widget/component a useful way to organise your files.
Getting Started
RmlUi is available in LuaUI (the game UI) with future availability in LuaIntro & LuaMenu planned, so it is already there for you to use. However, to get going with it there is some setup code that should be considered for loading fonts, cursors and configuring ui scaling, an example script is provided below. If you are working on a widget for an existing game, it is likely that the game already has some form of this setup code in, so you may be able to skip this section.
Setup script
Here is the “handler”, written by lov and ChrisFloofyKitsune, 2 of the people responsible for the RmlUi implementation.
-- luaui/rml_setup.lua
-- author: lov + ChrisFloofyKitsune
--
-- Copyright (C) 2024.
-- Licensed under the terms of the GNU GPL, v2 or later.
if (RmlGuard or not RmlUi) then
return
end
-- don't allow this initialization code to be run multiple times
RmlGuard = true
--[[
Recoil uses a custom set of Lua bindings (check out rts/Rml/SolLua/bind folder in the C++ engine code)
Aside from the Lua API, the rest of the RmlUi documentation is still relevant
https://mikke89.github.io/RmlUiDoc/index.html
]]
--[[ create a common Context to be used for widgets
pros:
* Documents in the same Context can make use of the same DataModels, allowing for less duplicate data
* Documents can be arranged in front/behind of each other dynamically
cons:
* Documents in the same Context can make use of the same data models, leading to side effects
* DataModels must have unique names within the same Context
If you have lots of DataModel use you may want to create your own Context
otherwise you should be able to just use the shared Context
Contexts created with the Lua API are automatically disposed of when the LuaUi environment is unloaded
]]
local oldCreateContext = RmlUi.CreateContext
local function NewCreateContext(name)
local context = oldCreateContext(name)
-- set up dp_ratio considering the user's UI scale preference and the screen resolution
local viewSizeX, viewSizeY = Spring.GetViewGeometry()
local userScale = Spring.GetConfigFloat("ui_scale", 1)
local baseWidth = 1920
local baseHeight = 1080
local resFactor = math.min(viewSizeX / baseWidth, viewSizeY / baseHeight)
context.dp_ratio = resFactor * userScale
context.dp_ratio = math.floor(context.dp_ratio * 100) / 100
return context
end
RmlUi.CreateContext = NewCreateContext
-- Load fonts
local font_files = {
}
for _, file in ipairs(font_files) do
Spring.Echo("loading font", file)
RmlUi.LoadFontFace(file, true)
end
-- Mouse Cursor Aliases
--[[
These let standard CSS cursor names be used when doing styling.
If a cursor set via RCSS does not have an alias, it is unchanged.
CSS cursor list: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
RmlUi documentation: https://mikke89.github.io/RmlUiDoc/pages/rcss/user_interface.html#cursor
]]
-- when "cursor: normal" is set via RCSS, "cursornormal" will be sent to the engine... and so on for the rest
RmlUi.SetMouseCursorAlias("default", 'cursornormal')
RmlUi.SetMouseCursorAlias("pointer", 'Move') -- command cursors use the command name. TODO: replace with actual pointer cursor?
RmlUi.SetMouseCursorAlias("move", 'uimove')
RmlUi.SetMouseCursorAlias("nesw-resize", 'uiresized2')
RmlUi.SetMouseCursorAlias("nwse-resize", 'uiresized1')
RmlUi.SetMouseCursorAlias("ns-resize", 'uiresizev')
RmlUi.SetMouseCursorAlias("ew-resize", 'uiresizeh')
RmlUi.CreateContext("shared")
What this does is create a unified context ‘shared’ for all your documents and data models, which is currently the recommended way to architect documents. If you have any custom font files, list them in font_files
, otherwise leave it empty.
The setup script above can then be included from your luaui/main.lua
(main.lua is the entry point for the LuaUI environment).
VFS.Include(LUAUI_DIRNAME .. "rml_setup.lua", nil, VFS.ZIP) -- Runs the script
The rml_setup.lua script included in base content only imports a font and cursor and doesn’t create a context or set scaling
Writing Your First Document
You will be creating files with .rml and .rcss extensions, as these closely resemble HTML and CSS it is worth configuring your editor to treat these file extensions as HTML and CSS respectively, see the IDE Setup section for more information.
Now, create an RML file somewhere under luaui/widgets/
, like luaui/widgets/getting_started.rml
. This is the UI document.
Writing it is much like HTML by design. There are some differences, the most immediate being the root tag is called rml instead of html, most other RML/HTML differences relate to attributes for databindings and events, but for the time being, we don’t need to worry about them.
By default RmlUi has NO styles, this includes setting default element behaviour like block/inline and styles web developers would expect like input elements default appearances, as a starting point you can use RmlUi documentation though these do not include styles for form elements.
Here’s a basic widget written by Mupersega.
<rml>
<head>
<title>Rml Starter</title>
<style>
#rml-starter-widget {
pointer-events: auto;
width: 400dp;
right: 0;
top: 50%;
transform: translateY(-90%);
position: absolute;
margin-right: 10dp;
}
#main-content {
padding: 10dp;
border-radius: 8dp;
z-index: 1;
}
#expanding-content {
transform: translateY(0%);
transition: top 0.1s linear-in-out;
z-index: 0;
height: 100%;
width: 100%;
position: absolute;
top: 0dp;
left: 0dp;
border-radius: 8dp;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
padding-bottom: 20dp;
}
/* This is just a checkbox sitting above the entirety of the expanding content */
/* It is bound directly with data-checked attr to the expanded value */
#expanding-content>input {
height: 100%;
width: 100%;
z-index: 1;
position: absolute;
top: 0dp;
left: 0dp;
}
#expanding-content.expanded {
top: 90%;
}
#expanding-content:hover {
background-color: rgba(255, 0, 0, 125);
}
</style>
</head>
<body>
<div id="rml-starter-widget" class="relative" data-model="starter_model">
<div id="main-content">
<h1 class="text-primary">Welcome to an Rml Starter Widget</h1>
<p>This is a simple example of an RMLUI widget.</p>
<div>
<button onclick="widget:Reload()">reload widget</button>
</div>
</div>
<div id="expanding-content" data-class-expanded="expanded">
<input type="checkbox" value="expanded" data-checked="expanded"/>
<p>{{message}}</p>
<div>
<span data-for="test, i: testArray">name:{{test.name}} index:{{i}}</span>
</div>
</div>
</div>
</body>
</rml>
Let’s take a look at different areas that are important to look at.
<div id="rml-starter-widget" class="relative" data-model="starter_model">
- Here, we bind to the data model using
data-model
. This is what we will need to name the data model in our Lua script later. Everything inside the model will be in scope and beneath the div. - Typically, it is recommended to bind your data model inside of a div beneath
body
rather thanbody
itself.
<div id="expanding-content" data-class-expanded="expanded">
<input type="checkbox" value="expanded" data-checked="expanded"/>
any attribute starting with data-
is a “data event”. We will go through a couple below, but you can find out more here on the RmlUi docs site.
- Double curly braces are used to show values from the data model within the document text. e.g.
{{message}}
shows the value ofmessage
in the data model. data-class-expanded="expanded"
applies theexpanded
class to the div if the valueexpanded
in the data model istrue
.<input type="checkbox" value="expanded" data-checked="expanded"/>
This checkbox will set the data model value indata-checked
to true.- When the data model is changed, the document is rerendered automatically. So, the expanding div will have the
expanded
class applied to it or removed whenever the check box is toggled.
There are data bindings to allow you to loop through arrays (data-for) have conditional sections of the document (data-if) and many others.
The Lua
For information on setting up your editor to provide intellisense behaviour see the Lua Language Server guide.
To load your document into the shared context we created earlier and to define and add the data model you will need to have a lua script something like the one below.
-- luaui/widgets/getting_started.lua
if not RmlUi then
return
end
local widget = widget ---@type Widget
function widget:GetInfo()
return {
name = "Rml Starter",
desc = "This widget is a starter example for RmlUi widgets.",
author = "Mupersega",
date = "2025",
license = "GNU GPL, v2 or later",
layer = -1000000,
enabled = true
}
end
local document
local dm_handle
local init_model = {
expanded = false,
message = "Hello, find my text in the data model!",
testArray = {
{ name = "Item 1", value = 1 },
{ name = "Item 2", value = 2 },
{ name = "Item 3", value = 3 },
},
}
local main_model_name = "starter_model"
function widget:Initialize()
widget.rmlContext = RmlUi.GetContext("shared") -- Get the context from the setup lua
-- Open the model, using init_model as the template. All values inside are copied.
-- Returns a handle, which we will touch on later.
dm_handle = widget.rmlContext:OpenDataModel(main_model_name, init_model)
if not dm_handle then
Spring.Echo("RmlUi: Failed to open data model ", main_model_name)
return
end
-- Load the document we wrote earlier.
document = widget.rmlContext:LoadDocument("luaui/widgets/getting_started.rml", widget)
if not document then
Spring.Echo("Failed to load document")
return
end
-- uncomment the line below to enable debugger
-- RmlUi.SetDebugContext('shared')
document:ReloadStyleSheet()
document:Show()
end
function widget:Shutdown()
widget.rmlContext:RemoveDataModel(main_model_name)
if document then
document:Close()
end
end
-- This function is only for dev experience, ideally it would be a hot reload, and not required at all in a completed widget.
function widget:Reload(event)
Spring.Echo("Reloading")
Spring.Echo(event)
widget:Shutdown()
widget:Initialize()
end
The Data Model Handle
In the script, we are given a data model handle. This is a proxy for the Lua table used as the data model; as the Recoil RmlUi integration uses Sol2 as a wrapper data cannot be accessed directly.
In most cases, you can simply do dm_handle.expanded = true
, but this only works for table entries with string keys. What if you have an array, like testArray
above? To loop through on the Lua side, you will need to get the underlying table:
local model_handle = dm_handle:__GetTable()
As of writing, this function is not documented in the Lua API, due to some problems with language server generics that haven’t been sorted out yet. It is there, however, and will be added back in in the future. it returns the table with the shape of your inital model. You can then iterate through it, and change things as you please:
for i, v in pairs(model_handle.testArray) do
Spring.Echo(i, v.name)
v.value = v.value + 1
end
-- If you modified anything in the table, as we did here, we need to set the model handle "dirty", so it refreshes.
dm_handle:__SetDirty("testArray") -- We modified something nested in the array, so we mark the top level table entry as dirty
Debugging
RmlUi comes with a debugger that lets you see and interact with the DOM. It’s very handy! To use it, use this:
RmlUi.SetDebugContext(DATA_MODEL_NAME)
And it will appear in-game. A useful idiom I like is to put this in rml_setup.lua
:
local DEBUG_RMLUI = true
--...
if DEBUG_RMLUI then
RmlUi.SetDebugContext('shared')
end
If you use the shared context, you can see everything that happens in it! Neat!
Things to Know and Best Practices
Things to know
Some of the rough edges you are likely to run into have already been discussed, like the data model thing, but here are some more:
Unlike a web browser a default set of styles is not included, as a starting point you can look at the RmlUi documentation though this doesn’t provide styles for form elements.
When using Context:OpenDataModel in Lua you must assign the return value to a variable, not doing so will cause the engine to crash when the model is reference in RML.
Input elements of type submit & button behave differently to HTML and more like Button elements in that their text is not set by the value attribute. (This is likely to be corrected in a future version)
The alpha/transparency value of an RGBA colour is different to CSS (0-1) and instead uses 0-255. The css opacity does still use 0-1.
List styling is unavailable (list-style-type etc.), you can still use UL/OL/LI elements but there is no special meaning to them, whilst you could use background images to replicate bullets there isn’t a practical way to achieve numbered list items with RML/RCSS.
Only solid borders are supported, so the border-style property is unavailable and the shorthand border property doesn’t include a style part (border: 1dp solid black;
won’t work, instead use border: 1dp black;
).
background-color behaves as expected, all other background styles are different and use decorators instead see the RmlUi documentation for more information on decorators.
There are two kinds of events: data events, like data-mouseover
, and normal events, like onmouseover
. These have different data in their scopes.
- Data events have the data model in their scope.
- Normal events don’t have the data model, but they do have whatever is passed into
widget
onwidget.rmlContext:LoadDocument
.widget
doesn’t have to be a widget, just any table with data in it.
For example, take this:
local model = {
add = function(a, b)
Spring.Echo(a + b)
end
}
local document_table = {
print = function(msg)
Spring.Echo(msg)
end,
}
dm_handle = widget.rmlContext:OpenDataModel("test", model)
document = widget.rmlContext:LoadDocument("document.rml", document_table)
<h1>Normal Events</h1>
<input type="button" onclick="add(1, 2)">Won't work!</input>
<input type="button" onclick="print('test')">Will work!</input>
<h1>Data Events</h1>
<input type="button" data-click="add(1, 2)">Will work!</input>
<input type="button" data-click="print('test')">Won't work!</input>
Best Practices
- To create a scalable interface the use of the dp unit over px is recommended as the scale can be set per context with SetDensityIndependentPixelRatio.
- For styles unique to a document, put them in a
style
tag. For shared styles, put them in anrcss
file. - Rely on Recoil’s RmlUi Lua bindings doc for what you can and can’t do. The Recoil implementation has some extra stuff the RmlUi docs don’t.
- The Beyond All Reason devs prefer to use one shared context for all rmlui widgets.
Differences between upstream RmlUI and RmlUI in Recoil
- The SVG element allows either a filepath or raw SVG data in the src attribute, allowing for inline svg to be used (this may change to svg being supported between the opening and closing tag when implemented upstream)
- An additional element
<texture>
is available which allows for textures loaded in Recoil to be used, this behaves the same as an<img>
element except the src attribute takes a texture reference
IDE Setup
To get the best experience with RmlUi, you should set up your editor to use HTML syntax highlighting for .rml file extensions and CSS syntax highlighting for .rcss file extensions.
In VS Code, this can be done by opening a file with the extension you want to setup, then clicking on the language mode in the bottom right corner of the window (probably shows as Plain Text). From there, you can select “Configure File Association for ‘.rml’” from the top menu that appears and choose “HTML” from the list. Do the same for .rcss files, but select “CSS”.