Scenes
How a scene file is built, what makes it play, and what it can see while it runs.
A scene is a .ulscene file anywhere in your mod. It returns a table: a few fields that say when the scene may play, and a RUN function that says what happens.
luareturn {
WHAT = { "have_a_drink", "order_espresso" }, -- after one of these actions…
WHERE = { "cafe", "coffee" }, -- …in one of these places…
WHEN = { 8, 12 }, -- …between 8:00 and 12:00
RUN = function(Game, Player, CurrentCompanion)
Game:SceneStart()
Game:Narrate("The barista remembers my order.")
Game:SceneEnd()
end,
}
The scene's name is its file name without the extension (barista.ulscene → barista). It is how other files refer to it and how a later mod replaces it; see Load order.
When a scene plays
After an action (the random roll)
Every time the player finishes an action, the game:
- collects every scene whose
WHATmatches the action, whoseWHEREmatches the current place, whoseWHENcontains the current hour, and which is not on cooldown; - shuffles them;
- runs them one by one until one accepts (does not
return false).
At most one scene plays per action. If none accepts, nothing happens.
Pacing. Scenes that only match through "all" are paced; scenes whose WHAT names the action itself are not. After a paced scene has played, no other paced scene plays for 2 game hours, and after that each action has a 40% chance to try one. The player sets both numbers in Settings. A scene that belongs to an action (the date's cinema scene, a quest step) should name that action in WHAT, so it is never skipped by the roll. This rule is new: the original game had no pacing, and with dozens of mods installed a scene followed every single action.
| Field | Matches | Special values |
|---|---|---|
WHAT |
Action ids (ACTION_UNIQUEID) |
"all" any action; "-id" excludes one when "all" is present; "none" never |
WHERE |
Venue tags (see Map and venues) | same: "all", "-tag", "none" |
WHEN |
{ from, to }, hours 0–24 |
A window that wraps ({ 22, 4 }) crosses midnight. Both ends are excluded. |
Tags are not case-sensitive. An empty WHAT or WHERE never matches: use that for scenes you only ever start by name.
luaWHAT = { "all", "-sleep", "-nap" }, -- after anything except sleeping
WHERE = { "all", "-home" }, -- anywhere but at home
WHAT = { "all" } never covers sleep (as in the original game): a scene that should follow sleeping names "sleep" itself.
Waking up. WHAT = { "wake_up" } plays when the player wakes from a night's sleep (sleep, sleep_hotel, any sleep_* action of a mod; not a nap), once per game day, after the sleep's own scene. Use it for the morning: a hangover, a letter on the mat, the day's first text. "all" does not cover it either.
luaWHAT = { "wake_up" },
WHERE = { "home" },
WHEN = { 5, 12 },
On purpose
| How | The scene… |
|---|---|
An action's SCENE_ALWAYS = "name" |
always runs when that action completes, instead of the random roll. See Actions. |
An interaction (an action with WHERE = { "interaction" }) |
runs with Actor set to the person the player clicked. See Actions. |
Game:followUp("name") in another scene |
runs when the next action completes, before anything else. |
Game:queueScene("name"), from a scene or a library callback |
runs as soon as nothing is on screen and no menu is open. |
Game:rollScenes("what"), from a scene |
once this scene is over, rolls the scenes whose WHAT lists what, as if an action of that id had just finished here (each scene's own odds still apply). An action with SCENE_ALWAYS skips the usual roll: Have a drink asks which drink, then calls rollScenes("drink_coffee") so the coffee moments still play. |
A quest's START |
runs when the player starts the quest from the journal. See Quests. |
-WorldMapScene=name on the command line |
runs at startup, for testing. |
Game:ForcedTrigger() is true when the scene was started on purpose (by SCENE_ALWAYS or followUp) rather than by the random roll, so a scene can skip its "how did I end up here" introduction.
Accepting or declining: return false
RUN can look around before committing. Returning false before showing anything declines, and the game tries the next candidate:
Every person a random scene needs has to be checked this way. A scene that takes getPerson("tag_x") or getSpecific("Dating") and goes on without checking isValid() runs with nobody in it for every player who has no such person; there are hundreds of random scenes, so an unchecked one shows up constantly.
luaRUN = function(Game, Player, CurrentCompanion)
local ex = Game:getSpecific("Ex")
if not ex:isValid() or Player:getStat("mood") > 50 then
return false
end
Game:SceneStart()
-- …
Game:SceneEnd()
end,
This is how a scene expresses conditions: who must be present, which stat thresholds, which mods are enabled. Anything that returns normally (including running off the end) counts as accepted.
The legacy fields WHO_RAW and OTHER_RAW still appear in ported scenes but the game does not evaluate them. Put conditions at the top of RUN as above.
Cooldowns
A scene that played should usually not play again right away. Put it on cooldown by name:
luaGame:timeout(24, "barista") -- not again for 24 game hours
if Game:isTimingOut("barista") then -- check another scene's cooldown
The random roll skips any scene on cooldown. The hours are scaled by the player's scene-frequency setting. By convention a scene times itself out with its own name; you can also time out a group of scenes under a shared id and check it yourself.
What RUN receives
| Name | What it is |
|---|---|
Game |
The scene API: dialogue, choices, time, people, story state. See the Game reference. |
Player |
The player. |
CurrentCompanion |
The person with the player right now, or an invalid person. |
Actor (global) |
The person an interaction was aimed at; nil otherwise. Game:getTarget() returns the same person. |
currentBuilding (global) |
The building the player is in, as a building alias. |
| Building aliases (globals) | Every alias created with Game:generateBuildingAlias, by name. |
| Your libraries' globals | Everything your Lua/*.lua files defined. See Libraries. |
A few read-only values come from Game:GetBuiltin(name):
| Name | Value |
|---|---|
HoursElapsed |
Game hours since the game started |
TimePassed |
Game hours since this scene started |
WHEN |
The current hour of the day |
Difficulty |
The game's difficulty setting |
CostOfLiving, SexualFreedom |
The current city's values (see Cities) |
Pauses
A scene runs top to bottom, but it pauses whenever it needs the player:
- on every
Game:SayandGame:Narrateline, until the player advances (or the auto-advance timer fires); - on
Game:GetChoice(), until the player picks an option; - on
Game:Sex(...), until the sex scene is finished; - on text prompts such as
Actor:enterFirstName().
You do not have to do anything to make this work: write the scene as if it ran straight through. Variables keep their values across pauses.
SceneStart and SceneEnd
Wrap every scene's visible part in Game:SceneStart() … Game:SceneEnd(). SceneEnd clears scene-scoped settings such as Game:blockPregnancy(). Settings made with Game:Filter, Game:setRaceGenerated or Game:PreciseModify also last only until the scene ends.
A complete example
lua-- Scenes/ynl_last_orders.ulscene
return {
WHAT = { "drink_alcohol", "ynl_order_shot" },
WHERE = { "bar", "pub" },
WHEN = { 23, 3 },
RUN = function(Game, Player, CurrentCompanion)
local bartender = Game:getPersonHere(false) -- someone here who is not a known NPC
if not bartender:isValid() then
return false
end
Game:SceneStart()
bartender:show()
Game:Say(bartender, "Happy", "Last orders! Anything else before I close?")
Game:Choice(0, nil, "One more for the road")
Game:Choice(1, Player:getStat("money") >= 20, "A round for everyone")
Game:Choice(2, nil, "I'm good, thanks")
local pick = Game:GetChoice()
if pick == 0 then
Player:modifyStat("money", -5)
Player:modifyStat("ynl_drunk", 15)
elseif pick == 1 then
Player:modifyStat("money", -20)
Player:modifyStat("interpersonal", 1)
Game:Narrate("The whole bar cheers.")
end
Game:timeout(20, "ynl_last_orders")
Game:SceneEnd()
end,
}
Next: Dialogue and choices.