Lua scripting

Mod UI

Put your own widgets on the world map and give your mod a settings page, in Lua.

Mods can draw their own interface in Lua, with the same themed widgets the game uses. There are two places for it:

Folder Where it shows Typical use
Map/Hud/*.lua On the world map, next to the base HUD, and optionally a page in the Mods panel A wanted level, a status badge, a counter
Menu/*.lua Only as a page in the Mods panel of the map's side rail Settings, a status sheet, a log

Both use the same script contract. The ul_Warrant module (stars on the map) and ul_Health (a health check page) are complete examples.

The script contract

A UI script returns a table:

lua-- Menu/ynl_settings.lua
local page = {
	MENU = { title = "Nightlife", icon = "cocktail", group = "UI", detail = "Bouncers, prices, closing time" },
	INTERVAL = 1.0,
}

function page.menu(UI, Game, Root)
	-- build the page; called each time the player opens it
end

function page.refresh(UI, Game, dt)
	-- called every INTERVAL seconds
end

return page
Field Meaning
MENU { title, icon, group, detail }: a row in the Mods panel. The icon is Map/Icons/<group>/<icon>.png
INTERVAL Seconds between refresh calls
build(UI, Game, Root) HUD only. Called once when the map HUD is built: create your on-map widgets here
refresh(UI, Game, dt) Called every INTERVAL seconds: update what you built
menu(UI, Game, Root) Builds the Mods-panel page, each time it opens

Game here is the same API scenes get (the global ModGame too), minus anything that needs a running scene: no Say, no Choice.

Placing a HUD piece

A HUD script says where its box goes. The base HUD never moves to make room: pick free space, or attach to a base element.

lualocal hud = {
	ATTACH = { to = "wallet", side = "left", gap = 12 },
	CARD = true,
	INTERVAL = 0.1,
}
Field Meaning
ATTACH { to, side, align, gap }: follow a base HUD element. side is left, right, above or below; align (for above/below) is start, center or end
ANCHOR { x = 0..1, y = 0..1 }: a point of the screen the box is pinned to (also its pivot), if not attached
OFFSET { x = px, y = px } from the anchor
CARD true wraps your box in the HUD's own card style; it hides itself when everything inside is hidden

Attachment targets: wallet, clock, speed, place, rail, camera, filters, player, minimap, activity, location, side, room, any HUD widget name, or a button id such as rail.people or cam.zoom_in. Attached boxes are re-measured every frame, so they follow the element as it grows.

Building widgets

Every UI:Create…(parent, …) call creates a widget inside parent and returns a handle you pass back to other UI: functions. A nil parent means the script's root.

luafunction page.menu(UI, Game, Root)
	local title = UI:CreateText(Root, "Nightlife settings")
	UI:SetTextStyle(title, "heading")

	local row = UI:CreateBox(Root, true)                     -- a horizontal row
	UI:SetFill(UI:CreateText(row, "Bouncer strictness"), 1)  -- takes the free space
	local value = UI:CreateText(row, tostring(Nightlife.bouncerStrictness()))

	local slider = UI:CreateSlider(Root, 0, 100, Nightlife.bouncerStrictness())
	UI:SetStep(slider, 5)
	UI:OnValueChanged(slider, function(v)
		Game:setGlobal("ynl_setting_strictness", v)
		UI:SetText(value, tostring(math.floor(v)))
	end)

	local reset = UI:CreateButton(Root, "Reset")
	UI:OnClick(reset, function()
		Game:clearGlobal("ynl_setting_strictness")
		Game:ShowMessage("Nightlife settings reset", 2)
	end)
end

Widgets

Create Is
CreateText(parent, text) A line or paragraph of text
CreateButton(parent, text) A themed button (OnClick)
CreateImage(parent, path) A picture from your mod's Images/ folder ("Nightlife/cocktail.png")
CreateBox(parent, horizontal) An invisible row (true) or column (false)
CreatePanel(parent, horizontal) A themed glass card; returns its inner box
CreateSeparator(parent, horizontal) A thin divider
CreateList(parent), CreateListItem(list, text) A vertical list of clickable rows
CreateScrollBox(parent) A scrolling column
CreateGrid(parent, columns) + SetGridSlot(w, row, col) A fixed-column grid
CreateWrapBox(parent) A row that wraps onto new lines
CreateOverlay(parent) Children stacked on top of each other
CreateSizeBox(parent, w, h) A fixed-size frame around one child
CreateBorder(parent, r, g, b, a) A coloured rectangle holding one child
CreateSpacer(parent, w, h) Empty space
CreateSlider(parent, min, max, value) A slider (OnValueChanged, fires on release)
CreateSpinBox(parent, min, max, value) A number you can drag or type (OnValueChanged, fires once the value is committed: release, Enter or focus loss)
CreateToggle(parent, on) A checkbox (OnToggleChanged)
CreateDropdown(parent, { options }, index) A combo box, index 0-based (OnSelectionChanged)
CreateInput(parent, text, hint) A text field (OnTextCommitted, on Enter or focus loss)
CreateProgressBar(parent, 0..1) A fill bar
CreateThrobber(parent) A busy spinner

None of the value callbacks run while the player is still dragging or typing: a slider, a spin box and a text field call yours once, with the final value. A callback may therefore do real work (save a setting, rebuild a list, move a person's morph) without slowing the drag. What does cost is rebuilding a whole panel from scratch on every change; update the one widget with SetValue/SetText instead.

Changing widgets

Call Effect
SetText(w, text), GetText(w) Text of a text, button or input
SetValue(w, v), GetValue(w) Value of a slider, spin box, progress bar or toggle
SetImage(w, path) Swap a picture
SetTextStyle(w, style) primary, secondary, disabled, bold, heading, small, accent, warning
SetFontSize(w, pt) Text size (map pages use 11–13)
SetWrap(w, true) Wrap text at the container's width
SetTint(w, r, g, b, a) Colour-multiply an image or text (0–1 per channel)
SetSize(w, width, height) Draw size of an image
SetPadding(w, left, top, right, bottom) Space around a widget in its row or column
SetAlign(w, "left|center|right|fill", "top|center|bottom|fill") Placement in its slot
SetFill(w, weight) Share of the free space in a row or column (0 = only what it needs)
SetVisible, SetEnabled, SetOpacity, SetTooltip, SetSelected The obvious
SetParent(w, parent), Destroy(w) Move or remove a widget

Every function is listed in the UI reference.

Keeping a page live

Keep the handles you need in locals of the script, and update them in refresh. A handle to a page that has since closed is harmless: the widget is simply no longer on screen.

lualocal bars = {}

function page.menu(UI, Game, Root)
	bars.drunk = UI:CreateProgressBar(Root, 0)
end

function page.refresh(UI, Game, dt)
	if bars.drunk then
		UI:SetValue(bars.drunk, Game:GetPlayer():drunkenness() / 100)
	end
end

Going somewhere from a page

A page's buttons can open another page, a person or the agenda, start an action or run a scene:

luaUI:OnClick(UI:CreateButton(Root, "Book a table"), function()
	UI:runAction("ynl_book")            -- the same as picking it in the Actions panel
end)
local check = UI:actionAvailability("ynl_book")   -- { ok = false, reason = "..." }
if not check.ok then UI:SetText(hint, check.reason) end
Method Does
UI:openPage(mod [, pageId]) Opens a Mods page. pageId is the page's MENU.id, else its file name; omit it for the mod's first page.
UI:openPerson(personOrId) Opens a person's sheet. Never spawns them.
UI:openPlace(buildingId) Opens a building's card on the map, framed (its programmes, its actions).
UI:openAgenda() Opens the agenda.
UI:closePage() Closes the open page.
UI:runAction(actionId) Starts an action, as if picked in the Actions panel.
UI:runScene(sceneName) Closes the page and runs a scene.
UI:actionAvailability(actionId) { ok, reason }, the check the action lists use.

Each returns true when it will happen and false (with the reason in the log) when it cannot: an unavailable action, a page or person that is gone, a scene while another is running. It happens on the next frame, after your button callback has returned. That is what makes runScene safe: a scene pauses on its dialogue, which cannot be done from inside a button callback, so never call a scene's RUN yourself from one.

A panel inside a scene

A scene can stop on a card of its own: a phone screen, a picker, a ledger. Game:showPanel(title, build) opens it over the scene and waits; build(UI, root, close) fills it with the same builder as above, and the value given to close is what showPanel returns. The card's X button returns nil.

lualocal pick = Game:showPanel("Phone", function(UI, root, close)
	UI:CreateText(root, "Three new messages.")
	local row = UI:CreateBox(root, true)
	UI:OnClick(UI:CreateButton(row, "Call Mum"), function() close("mum") end)
	UI:OnClick(UI:CreateButton(row, "Delete all"), function() close("bin") end)
end)
if pick == "mum" then
	Game:Narrate("She picks up on the first ring.")
end

While the card is up, UI is also a global, the dialogue box and choices underneath do not react, and closing tears every widget down. Call close from a callback, never twice; the scene goes on the next frame.

Errors

An error inside a callback (OnClick, OnValueChanged…) is logged and swallowed: your page stops reacting, the game does not. A script that does not return a table is skipped with a log line.

Icons and images

  • CreateImage and SetImage read from Images/ in any enabled mod, later mods winning on the same path, so you can also restyle another mod's pictures.
  • MENU.icon reads Map/Icons/<group>/<icon>.png. Use white glyphs on transparency and tint them with SetTint.

Themes

A theme is a JSON file, UI/Themes/<Id>.uluitheme, with its fonts and pictures beside it in the mod. Every themed widget (the main menu, the pause menu, the settings, the Mods panel, mod pages built with UI.Create*) takes its colours, fonts, corner radii and pictures from the active theme. ul_Base ships two, Default (dark) and White; the player picks one in the main menu's top bar or in Settings, and the choice is saved by Id. A mod adds a theme by dropping a file; a mod later in the load order replaces one by shipping the same Id. Pick a theme for a test run with -UITheme=<Id> on the command line.

Every field is optional and falls back to the dark defaults. Colours are #RRGGBB or #RRGGBBAA, as read off a mockup (sRGB). File paths are relative to the mod root; a path another enabled mod ships also works, so a theme may lean on ul_Base's icons and fonts.

json{
  "Name": "White",
  "Light": true,
  "Colors": {
    "Text": "#1C2430", "TextSecondary": "#5C6675", "TextDisabled": "#9AA3B0", "TextWarning": "#C77A12",
    "SectionLabel": "#7A8494",
    "Accent": "#1E88E5", "AccentHover": "#3D9BF0", "AccentPressed": "#1565B8", "Selected": "#1E88E5", "Glow": "#1E88E5",
    "PanelBackground": "#FFFFFF", "PanelBorder": "#B9C6D8", "Scrim": "#EEF2F8F2",
    "PillFill": "#FFFFFF", "PillBorder": "#C9D3E0",
    "SliderTrack": "#D5DDE8", "SliderFill": "#1E88E5", "SliderHandle": "#FFFFFF",
    "TabBackground": "#F3F6FA", "TabSelected": "#1E88E5", "Separator": "#1C243018"
  },
  "Panel": { "Opacity": 0.84, "BorderOpacity": 0.7, "BorderThickness": 1, "RowFillOpacity": 0.6, "RowBorderOpacity": 0.55, "SeparatorThickness": 1, "Glow": 0.7 },
  "Radius": { "Small": 6, "Medium": 8, "Large": 10 },
  "Spacing": { "PaddingSmall": 8, "PaddingMedium": 16, "PaddingLarge": 24, "GapSmall": 6, "GapMedium": 12, "GapLarge": 20 },
  "Fonts": {
    "Regular": "UI/Fonts/Montserrat-Medium.ttf",
    "Bold": "UI/Fonts/Montserrat-SemiBold.ttf",
    "Heading": "UI/Fonts/Montserrat-Bold.ttf",
    "Small": "UI/Fonts/Montserrat-Medium.ttf",
    "Sizes": { "Body": 15, "Small": 12, "Bold": 17, "Heading": 28 }
  },
  "Images": {
    "ButtonGlow": "UI/Images/ButtonGlow.png", "ButtonGlowBleed": 24, "Logo": "UI/Images/LogoDark.png", "LogoHeight": 92, "IconFolder": "UI/Icons",
    "LoadingBackground": "Loading/background.png", "LoadingLogo": "Loading/logo.png"
  },
  "Stage": {
    "Backdrop": "MainMenu/BackdropWhite.png", "CreatorBackdrop": "MainMenu/BackdropWhite.png",
    "ExposureBias": -1.1, "BackdropBrightness": 2.2,
    "KeyLight": 1.0, "KeyLightTint": "#FFF6EC", "FillLight": 1.3, "FillLightTint": "#F2F6FF", "RimLight": 1.0, "RimLightTint": "#FFFFFF",
    "Floor": true, "FloorColor": "#DDE3EC", "FloorRoughness": 0.22, "CameraSway": 4
  },
  "Motion": { "Transition": 0.34, "Stagger": 0.035 }
}
  • Fonts are .ttf/.otf files loaded from the mod at runtime, or the name of a font asset shipped with the game (Montserrat-Bold). Bold and Heading default to Regular.
  • Images.ButtonGlow is a white rounded card with an outer blur, drawn as a nine-slice behind the selected row and the main call to action and tinted with Colors.Glow; ButtonGlowBleed is how many pixels of blur extend past the card on each side. Logo replaces the wordmark top-left (sized by height). LoadingBackground and LoadingLogo dress the loading screen (full-screen picture, logo over it); without them the last mod's Loading/background.png and Loading/logo.png are used. IconFolder holds white PNG glyphs named as the game asks for them (play, newgame, load, settings, mods, donate, discord, commission, credits, exit, save, options, gallery, sliders, globe, tutorial, chevron-left, chevron-right, chevron-down, dot); a theme can point at its own set.
  • Stage is how the main menu and the character creator light their 3D stage: the picture on the plane behind the person (Backdrop; CreatorBackdrop gives the creator its own, else it shares the menu's), the camera's exposure, multipliers and tints on the four studio lights (key, fill, two rims), a glossy floor under the person, and a slow camera drift in centimetres (0 holds still).
  • Motion is the menu's transition time and the stagger between rows (0 turns the motion off).

Older mods may still point UI/Theme.txt at a UUITheme asset, and individual built-in menus at a widget class through UI/<MenuId>.txt; both still load but need engine content, and nothing new should be authored that way.

Limits

  • UI scripts are for the map and the Mods panel; a scene builds its own UI in a panel (above).