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.
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'sActor. A scene with atargetrole is offered to NPCs withtarget = "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.