Run your first Lua script¶
After you configure Lua support, you drive a Tick UI
screen from Lua in three parts. Host a LuaScriptRunner inside a
BaseGameUI subclass, hand it a script, and run it each frame. The
script is the per-frame UI body. Tick UI recompiles it when it
changes and re-runs it every frame.
Host the runner¶
LuaScriptRunner owns the interpreter. Construct one from your UI
instance, give it a script, and call RunFrame() from inside
DoUI:
public class LuaUIHost : BaseGameUI
{
[TextArea(6, 20)] public string initialLuaSource;
private LuaScriptRunner m_runner;
protected override void Start()
{
base.Start();
m_runner = new LuaScriptRunner(GetUI());
m_runner.SetScript(initialLuaSource);
}
// Call this to swap the script live (for example, from the browser).
public void SetScript(string code) => m_runner.SetScript(code);
protected override void DoUI(UI ui)
{
ui.BeginFrame(new Vector2Int(Screen.width, Screen.height));
ui.Fill(Color.clear);
m_runner.RunFrame();
if (m_runner.HasError)
{
using (ui.Auto().Bottom(120).Pad(8))
{
ui.FillRounded(UI.FromHex("#330000"), 6);
using (ui.Auto().Pad(8))
ui.Text(m_runner.Error, "roboto_mono", 14,
UI.FromHex("#FF6666"), TextAlign.TopLeft, Wrap.Wrap);
}
}
ui.EndFrame();
}
}
Attach this component to a GameObject and configure its
render-pipeline integration like any other BaseGameUI (refer to
Set up Tick UI). Then paste a script
into initialLuaSource.
Write a script¶
A single TickLuaApi is exposed to the script as the global ui.
It mirrors the C# API with three conventions:
- Layout uses explicit push and pop: every layout call pushes a
rect, and a matching
ui:Pop()removes it. - Colours are strings:
"#RRGGBB"or"#RRGGBBAA". - Sizes are a number or a percentage string:
380means 380 pixels and"50%"means half the parent.
A forgotten ui:Pop() is unwound at end of frame, so it cannot
corrupt the next frame.
clicks = clicks or 0
ui:Center(240, 64)
ui:FillRounded(ui:IsHovered() and "#2F7D99" or "#23607A", 10)
ui:Text("Clicked " .. clicks .. "x", "roboto_mono", 20,
"#FFFFFF", "MiddleCenter")
if ui:Button("btn") then clicks = clicks + 1 end
ui:Pop()
Lua globals such as clicks persist between frames, so the counter
above survives. The facade covers most of the practical UI
surface: layout edges and schemas, drawing primitives and effects,
text, widgets (Button, Textbox, Slider, scroll views, panels
and modals), hover animation, and input reads. For the complete
list, refer to TickLuaApi.cs in Source/Extra/Scripting/.
Hot-reload the script¶
SetScript recompiles on the next frame, so swapping the source
live updates the UI without a domain reload. The web playground
calls it from JavaScript:
Subscribe to m_runner.ErrorChanged and m_runner.Printed to
forward compile errors and print(...) output to a browser console
or an in-game log.
Pass game state in¶
Expose a read-only view object, never your real gameplay classes, so a script reaches only what you choose:
[MoonSharpUserData]
public sealed class GameStateView
{
public int Health { get; }
public int Ammo { get; }
public bool IsBossActive() => /* ... */;
}
// once, at startup:
UserData.RegisterType<GameStateView>(); // and add it to link.xml
m_runner.SetGlobal("game", myGameStateView);
ui:Text("HP: " .. game.Health, "roboto_mono", 18, "#FFFFFF")
if game:IsBossActive() then ui:Fill("#330000") end
Because it is a live reference, set it once and the script always
sees current values. Add each registered type to the link.xml so
IL2CPP and WebGL builds do not strip it (refer to
Configure Lua support).
Run untrusted scripts safely¶
The layer is built to run untrusted input:
- The Lua environment is a hard sandbox with no
io,os,file, orloadstring. - Each frame runs under an instruction budget, so an infinite loop is aborted instead of freezing the host.
- A forgotten
ui:Pop()cannot unbalance the rect stack.
Keep the facade, plus any view objects you register, as the entire surface a script can touch.
Translate a script to C¶
The facade is a close wrapper over the real UI API, so a script
prototyped in the playground translates to production C# almost
mechanically. Drop the colon, terminate with a semicolon, and unwrap
the value tokens. "#23C9FF" becomes UI.FromHex("#23C9FF"), and a
"50%" size becomes Size.Percent(0.5f). Persistent Lua globals
become fields on the MonoBehaviour, where game state lives.
Additional resources¶
- Configure Lua support: the MoonSharp and
TICK_LUAsetup this page assumes. - Set up Tick UI: wiring a
BaseGameUIsubclass onto a camera. - Frame lifecycle: where
RunFrameslots in betweenBeginFrameandEndFrame.