Lua scripting

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:

  1. collects every scene whose WHAT matches the action, whose WHERE matches the current place, whose WHEN contains the current hour, and which is not on cooldown;
  2. shuffles them;
  3. 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.

note

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:Say and Game:Narrate line, 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.