Lua scripting

Scenes as tables

A scene as a Lua table, told in beats between roles. One file plays for the player and for NPCs; the prose, the choices and the effects are readable without running anything.

warning

Format in progress, engine in build (2026-10-25). The keys below are the vocabulary of Tools/ModSchemas/formats/ulscene.schema.json, read as a list of keys, not as a JSON format: a scene is a Lua table. The return { RUN = function … } scenes of Scenes keep working throughout, loaded as script scenes.

A scene is a Lua file, Scenes/**/*.ulscene, that returns a table whose id equals the file name. It declares who takes part (roles), when it applies (filter, place, hours, trigger) and what happens as a sequence of beats: lines, a choice, effects, and where to go next. Where a scene needs a calculation, it writes a function in the same table, same language, same file. The same table plays for the player when they hold a role, and for two NPCs when they do not; there is no .ulsocial sheet beside it any more.

The example on this page is a real one: ul_Debts/Scenes/ul_db_press_visit.ulscene, the collectors at the player's door. Its functions call the mod's library (Debts, in Lua/ul_debts_lib.lua) as before.

The file

lualocal D = Debts

return {
	id = "ul_db_press_visit",
	content = { "violence" },
	npc = false,
	what = { "all" }, where = { "all" }, when = { 0, 24 },

	filter = function(Game, S)
		if not D or not Game:isAtHome() then return false end
		local c = D.dueFor(2)
		if not c then return false end
		S.vars.creditor = c
		return true
	end,

	roles = {
		initiator = { doc = "The player, who owes." },
		collector = { doc = "The one who talks. The lender's people.", from = function(Game, S) return D.heavy(Game, S.vars.creditor) end },
		loved = { doc = "Someone the player loves, when the family setting is on.", optional = true, from = function(Game, S) return D.familyOn() and D.lovedOne(Game) or nil end },
	},

	beats = { start = { --[[ … ]] } },
}
Key Means
id The file name without extension.
feature A feature of the mod's .ulmod (MODULE_FEATURE); the scene exists only while it is on.
content Content tags of this scene, on top of the mod's and the feature's.
filter function(Game, S) -> bool, run before anyone is bound. False: the scene does not apply.
roles Who takes part (below).
what, where, when Player side: the action ids after which the scene may roll, the venue tags (-tag excludes), the hours. The same as today.
trigger NPC side: { ambient = 2, actions = { "go_home" }, targetMayBeBusy = false }: the weight among the place's ambient scenes, the NPC actions it may follow.
target "nearby" (default when a target role exists) or "self".
npc false: the player only, never offered to NPCs.
visible Default true: played in the room (bubbles, animations) when the player is there.
outcome Default true: a player scene with a choice is an Outcome. false for utility scenes.
maxParticipants, targetMinimumStats, initiatorMinimumVars The NPC-side gates the .ulsocial used to carry.
start The first beat, "start" by default.
beats The scene.
script Instead of beats: a function (Game, Player, CurrentCompanion) with the classic API, a fully scripted scene.

Roles

Every person in the scene holds a role. Three names mean something to the engine:

  • initiator: whoever runs the scene. The player when they started it; the NPC who starts it in room life.
  • target: the other person, the scene's Actor. A scene with a target role is offered to NPCs with target = "nearby".
  • companion: the initiator's companion.

Any other name (collector, loved, witness) is a role the scene fills itself.

Role key Means
doc Who this is, for the reader.
from function(Game, S) -> person that finds or generates the person (a collector, a caller). Without it the engine binds the role from the situation (the target, the companion).
filter function(Game, person, roles, S) -> bool that must accept the person. A scene whose role finds nobody does not apply.
optional The scene plays without this person; a cond can test whether they are there (Scene.valid(R.loved)).
role target only: a relationship role the target must have towards the initiator ("Girlfriend", "Boss"…).
gender, worker, jobTitles Who may hold the role.

The player is bound to the role they occupy. Their lines and their choices are shown; the other roles say theirs. When no role is the player, the scene plays between NPCs: off screen (effects only), or in the room when the player is there and visible is true. One file replaces the player scene, the .ulsocial sheet, UL.seen and Game:isNpcSimulation().

Beats

beats is a table of named steps. Each runs in this order: stage, lines, effects, then one of choice, roll, run, next or ["end"].

luatalk_lost = {
	lines = {
		{ who = "collector", mood = "Angry", text = "Everybody's got a plan. You know what I've got? A van." },
		"The quiet one steps past me into the hall and takes the first thing within reach…",
	},
	effects = { { stat = "mood", amount = -8 } },
	next = "done", result = "talked and failed",
},

Stage

Key Does
show, hide Roles brought on stage or sent off.
faceEachOther Two roles turn to each other.
secondScreen A role speaks from the second screen (a phone call, someone behind a door); false ends it.
animatePair { roleA, roleB, pairTag }: a paired animation (a kiss, a hug).
animate { role = "activity id" }.

Lines

A line is a string (the narrator) or a table:

Key Means
who A role name, or "narrator" (the default).
mood A mood recipe (CharGen/moods.json): Neutral, Happy, Annoyed, Scared, Furious, Flirty…
text The line.
oneOf Several wordings; one is drawn per run.
cond A condition (below); the line is skipped when it fails.
id Defaults to <scene>.<beat>.<n>: the key of translations and re-voicing (Text pipeline).
lua{ who = "collector", cond = function(Game, R) return Scene.valid(R.loved) end,
  text = "Nice photo in your hall. Is that {loved}? Lives over by the park, doesn't {loved.he}?" },
"On the step: {=lender}. One of them, {collector}, does all the talking.",

Templates in text: {collector} is the role's name, {loved.he}, {loved.him}, {loved.his}, {loved.He} their pronouns, and {=lender} a value a function put in S.vars (here S.vars.lender, set by the collector role's from). The player's own line is written in the first person, as today.

Effects

Effects are data, or a function. A table is one effect; its kind is the first known key:

Kind Example Does
stat { stat = "mood", amount = -10, who = "target" } Adds to a stat (set to set it). who defaults to the player.
var { var = "ul_Debts.press_next", set = "now" } A declared state var: amount adds, set sets ("now", or a role name for a person var), clear = true clears.
money { money = -100, reason = "Paid the collectors" } Through the Ledger.
relation { relation = "rapport", from = "initiator", to = "target", amount = 2 } Rapport (default) or a pair stat.
witness { witness = "assault", actors = { "initiator" }, targets = { "target" }, severity = 3 } A witness act; discreet as in Witness:act.
memory { memory = "ul_Debts.was_hit", who = "target", about = "initiator" } A memory on a person about another.
history { history = "ul_db_beaten", who = "target" } A history line.
service { service = "injuries", call = "bruise", who = "target", args = { severity = 2 } } A service call; optional = true when the mod only uses it.
quest { quest = "ul_db_main", step = "visited" }, start, finish, fail The journal.
schedule { schedule = "ul_db_callback", handler = "Debts.callback", hours = 24, payload = {} } An Events call later.
notify { notify = "ul_db_visit", title = "…", message = "…", icon = "…" } A notification.
phone { phone = "collector", text = "Thursday." } A text message from a role.
a function function(Game, R, S) D.calm(S.vars.creditor, 1) end The escape hatch for what the mod's rules decide (which creditor, how much).

amount (and value, hours, odds) is a number, a { min, max } range, or a function (Game, R, S) -> number. cond on an effect skips it when false.

Conditions

Used by cond on lines, effects and options:

lua{ stat = "money", op = ">=", value = 100 }
{ var = "ul_Debts.owed", op = ">", value = 0, who = "target" }
function(Game, R, S) return D.money() + D.dirty() > 0 end
{ all = { { stat = "age", op = ">=", value = 18 }, { ["not"] = { feature = "bookie" } } } }
{ npc = true }

A function is a predicate; true/false are what they say. npc = true holds only in an NPC run, false only for the player.

Choice

luachoice = { who = "initiator", options = {
	{ id = "pay", label = "Give them the cash", tooltip = "Everything in the flat that is cash",
	  cond = function() return D.money() + D.dirty() > 0 end, reason = "There is no cash in the flat",
	  effects = { function(Game, R, S) S.vars.paid = D.cash(D.payCreditor(S.vars.creditor, D.creditorOwed(S.vars.creditor))) end },
	  next = "paid" },
	{ id = "talk", label = "Talk them down", tooltip = "A date, a promise, a plan. Interpersonal.",
	  odds = function() return D.chance("interpersonal", 55) end, next = "talk" },
	{ id = "fight", label = "Get off my doorstep", tooltip = "Martial arts. People will hear.",
	  odds = function() return D.chance("martial", 60) end, next = "fight",
	  npc = { base = -0.3, traits = { dominance = 0.6 }, behaviors = { physical_aggression = 0.7 } } },
} },
Option key Means
label The petal text; keep it under about 22 characters, detail goes in tooltip.
cond, reason With reason, a failed condition shows the option greyed with that reason; without, the option is hidden.
odds Printed after the label (0..100 or 0..1), the way the greyed odds work today.
effects Applied when the option is taken.
next The beat to go to; absent, the scene ends.
result The Outcome result recorded when the scene ends by this path.
npc How an NPC weighs the option: score = base + traits × personality + behaviors × personality + needs. This is the .ulsocial rule, moved next to the option it scores, so there is no index to keep in step between two files.

who documents whose decision it is. The player decides when they hold that role; an NPC decides by the options' npc rules, and when an option has no npc block, by the scoring of its effects (the NPC Mind plan). The npc blocks are corrections, not the rule.

Roll, run, next, end

Key Does
roll { odds = function() return D.chance("sneak", 40) end, success = "hide_won", failure = "hide_lost" }: a chance, two beats.
run function(Game, R, S) -> next beat name or nil [, result]: the mod's rules pick the way (paid sends to square or partial).
next The beat that follows.
["end"] true: the scene ends here (end is a Lua keyword, hence the brackets).
result The Outcome result when the scene ends by this beat.

A beat with none of these ends the scene.

What happens on the NPC side

Nothing to write. When room life runs a scene between two NPCs (its trigger), the engine binds the roles from the people present (target = "nearby"), applies the filters, walks the beats, picks each choice by the options' npc rules or effects, applies the effects, and records what the player could see or hear (visible) and the witness acts. The all-or-nothing rule holds: a beat that fails leaves nothing behind.

The old .ulsocial sheet is gone with it: maxParticipants, targetMinimumStats, initiatorMinimumVars, target and trigger are the gates it used to carry, now in the scene itself.

Outcomes, for free

A scene where a role is the player and a beat has a choice is an Outcome by default: begun before the choice, checked after, committed once, with the result strings as its results (Outcomes). Set outcome = false on utility scenes.

Functions in the table

Every calculation is a function written where it is used: the scene filter, a role's from and filter, an odds, a cond, a bare function among effects, a beat's run, or the whole scene as script. They receive (Game, R, S): R holds the bound people by role name (R.collector), S.vars is the scene's scratch table whose values the lines read as {=name}. The runtime is Scene (ul_Base/Lua/_scene_runtime.lua): Scene.valid(p) says whether a role is bound; State and Services are the two other globals (State, Services).

The rule of thumb from the pilot: the table holds the prose, the choices and the plain effects; the functions hold what needs the mod's rules, and are thin calls into the mod's library so that no rule lives in two places. A tool, the translation table and the re-voicer read the table without running a function.