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
CreateImageandSetImageread fromImages/in any enabled mod, later mods winning on the same path, so you can also restyle another mod's pictures.MENU.iconreadsMap/Icons/<group>/<icon>.png. Use white glyphs on transparency and tint them withSetTint.
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/.otffiles loaded from the mod at runtime, or the name of a font asset shipped with the game (Montserrat-Bold).BoldandHeadingdefault toRegular. - 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;ButtonGlowBleedis 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'sLoading/background.pngandLoading/logo.pngare 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;CreatorBackdropgives 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).