Skip to content

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: 380 means 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:

unityInstance.SendMessage("LuaUIHost", "SetScript", luaText)

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, or loadstring.
  • 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