NPC life
How the town lives without the player - which actions NPCs take, which scenes happen to them, and how to declare your mod's scenes for NPCs.
Every adult the game knows lives on their own, whether the player is around or not. A scheduler picks what each one does next from the actions they are allowed to take, weighted by their personality and needs; some of those actions play a scene. When it happens in the room the player is standing in, it is shown: the animation plays, the lines appear as speech bubbles over whoever says them. Elsewhere it resolves offscreen with exactly the same result. The player only watches.
Letting NPCs take an action
An action is player-only until it says otherwise. Add an NPC block to the .ulaction:
luareturn {
NPC = {
ENABLED = true,
BASE = 0.12, -- how attractive it is to anyone
TRAITS = { extraversion = 0.3, openness = 0.2 }, -- who likes it more (or less, when negative)
NEEDS = { { STAT = "energy", TARGET = 55, SCALE = 55, WEIGHT = 0.6 } },
MINIMUM_STATS = { energy = 10 }, -- never picked below these
SCENE = "book_discussion", -- optional: always plays this scene
SCENES = { "music_discussion", "sing_along" }, -- optional: plays one of these
REQUIRES_NEARBY = true, -- optional: needs someone else there
},
ACTION_UNIQUEID = "read_a_book",
...
}
WHERE, WHEN, CONDITIONS and EFFECTS apply to NPCs exactly as to the player: an NPC only reads at the library if they are at the library, and a meal costs them its price.
Traits are the twelve personality dimensions of ul_Base/SocialSimulation/foundation.ulsocial: openness, conscientiousness, extraversion, agreeableness, emotional_reactivity, empathy, honesty, impulsivity, risk_tolerance, dominance, attachment, vindictiveness.
NPC = false keeps an action player-only on purpose.
NPC field |
Meaning |
|---|---|
ENABLED |
true to let NPCs take it. |
BASE, TRAITS, NEEDS, MINIMUM_STATS |
As above. |
BEHAVIORS |
Like TRAITS, with the behavior ids of ul_Base/Personality/base.ulpersonality (gossip, spending, helping, risk_taking…). |
RISK, PRESSURE_WEIGHT |
Weights of the world mode's risk bias and of the NPC's money pressure (debt, no home). |
SCENE, SCENES |
One scene, or up to 16 to pick from. Without either, the action's SCENE_ALWAYS plays. |
REQUIRES_NEARBY |
Only when someone else is at the place. |
HISTORY, HISTORY_MAJOR |
A history event type recorded each time an NPC does it, and whether it is major. |
Some actions can never be NPC actions, whatever the block says: interactions (WHERE = { "interaction" }), and any action with WHO, WHO_LUA, AVAILABLE, END_PARTY, OUTDOOR or WEATHER, a BUFF effect, an Interacting:, rapportwithplayer or age effect, or a SCENE_ALWAYS (or NPC.SCENE) that no .ulsocial declares. A FURNITURE action is taken only at its FURNITURE_SURE places, and one that costs money only by an NPC who has it.
Declaring a scene for NPCs
A scene never runs for NPCs unless a .ulsocial file under your mod's SocialSimulation/ folder declares it. The scene file itself is unchanged: the same RUN runs with the NPC as Player and the other person as Actor.
json{
"version": 1,
"scenes": [
{
"scene": "ul_talk_joke",
"target": "nearby",
"where": ["cafe", "bar", "pub"],
"fromHour": 8,
"toHour": 23,
"ambient": 1,
"libraries": ["ul_Core/ul_core.lua", "ul_Core/ul_event_director.lua"],
"choices": [
{ "point": 0, "index": 0, "base": 0.05, "traits": { "agreeableness": 0.4 } },
{ "point": 0, "index": 1, "base": 0, "traits": { "dominance": 0.6, "agreeableness": -0.4 } }
]
}
]
}
| Field | Meaning |
|---|---|
scene |
The .ulscene file name, without extension. |
target |
"self" (the NPC alone) or "nearby" (someone at the same place becomes Actor). |
where, fromHour, toHour |
Where and when it may happen. "home", "work", "all" and "-tag" work as in WHERE. |
actions |
The NPC actions it hangs on: it may play when an NPC does one of them. |
ambient |
A weight. Instead of hanging on an action, the scene may happen during whatever the NPC is doing, where and when it allows. Use it for the scenes the player meets anywhere (WHAT = { "all" }). |
libraries |
"mod/file.lua" from a mod's Lua/ folder, loaded into the scene's private state first (at most 16). optionalLibraries are skipped when their mod is off. |
choices |
How an NPC answers each Game:Choice. point is the 1-based GetChoice it applies to, or 0 for every one; index is the option. The option with the best base + trait/behavior/need score wins. A choice point with no rule for an available option makes the scene refuse. |
maximumParticipants |
How many times the scene may call Game:generatePerson() (1-4, default 1). In an NPC scene that call takes no arguments and creates nobody: the first call returns Actor, each next one someone else already at the same place. 2 is Actor and one more. When nobody fits, the scene is refused. |
targetRole, targetGender, targetWorker, targetJobTitles, targetMinimumStats |
Who qualifies as Actor. Minors never do. Do not test age in targetMinimumStats: a person the player never met reads 0. |
targetFamilyPresent |
0-8: how many of the initiator's family must be at the place. |
initiatorMinimumVars |
{ "var": minimum } (up to 8): only NPCs whose variables reach these may draw the scene. Use it for scenes that only make sense for a few people, so they are not drawn and refused all day. |
How often ambient scenes happen overall is autonomy.ambientSceneChance in ul_Base/SocialSimulation/daily_life.ulsocial (0.1: one action in ten carries one).
Ambient scenes also play in front of the player: while the player stands in a room, every autonomy.watchedSceneMinSeconds–watchedSceneMaxSeconds real seconds (25–50 by default, 0 turns it off) two people standing there play one, with its lines as bubbles over them. A scene whose where names the room's place ("bar") is drawn about forty times more often than an "all" one, so a venue's own scenes are what the player sees. The strangers who play one become people of the town. The people in a scene walk up to each other before the first line.
A scene can declare "targetMayBeBusy": true: then, once per scene playing in the room, someone free may run it on one of the two people talking (autonomy.watchedBusyTargetChance, 0.35). The mark stays in their conversation, the initiator walks up to them, and the scene's own lines over the mark are skipped. Use it for what a distracted person invites (a pickpocket, a spiked drink), and read Actor:isDistracted() to make it more likely to work.
What an NPC scene can do
NPC scenes run in an isolated state and commit as a whole: if anything fails, nothing happens. Stats, variables, relationships, contacts, timeouts and money go through; anything that only makes sense for the player (quests, the HUD, the camera, saving) makes the scene refuse. Game:isNpcSimulation() is true there, for the rare branch that has to differ.
Actor:dress(),strip(),undress()andanimate("…"),Game:AnimatePair(a, b, tag),Game:SayandGame:Narrateare presentation: shown on the bodies when the player is in the room, nothing otherwise. A stripped NPC dresses again from their own wardrobe.Game:Sex(a, b)between NPCs is one outcome, not a session:ul_Base'snpcSexrules (condom, conception, STD, history) decide what follows, and one generic clip plays if the player is there.Game:Filter(...)picks that clip.Actor:isInterestedIn(other)reads the orientation each person was generated with.Game:LoseDating(person): the initiator and that person split up (dating and marriage become an ex), published with the rest of the scene.Witnessworks as in a player scene, recorded when the scene succeeds;knownByrows addwhoandrole(Witnesses).Actor:getVar(k)is nil when unset, as in a player scene (getActorVarandGame:getGlobalstay 0). Library code that registers listeners withEvents:onloads fine: the registration is ignored, an NPC scene never fires events.getSpecific/getRelatedPersonanswer an invalid person for the player-only lists an NPC has nothing in (Coursemate,Prostitute,Doctor,Poly...), so the scene declines as it would for anyone without one.ExDatingis the NPC's exes,Relativeany adult relative.
Shared world state
In an NPC scene, Game:getGlobal and Game:setGlobal belong to the NPC: each person keeps their own copy. A mod whose state the whole town shares (an election, a city's grievances) declares its prefixes once in any of its .ulsocial files:
json{ "version": 1, "worldGlobals": ["pol_", "ch_"], "scenes": [ ... ] }
Globals starting with those prefixes are then read from the game's own state in NPC scenes, and their writes are published with the rest of the scene when it succeeds (a refused scene changes nothing). If another scene changed a value this scene read before it finished, the scene is refused and tried again later.
Life choices
Once a day each NPC also decides how to get through it: ask someone for help, steal, extort, pick a fight — or one of the life choices mods declare in a .ulsocial lifeChoices array (ul_Base/SocialSimulation/dark_life.ulsocial ships selling sex, using and dealing drugs, and forcing someone). Every option is scored and the highest wins, so a choice only happens to the people whose circumstances and personality push them there.
json{
"lifeChoices": [
{
"id": "use_drugs",
"history": "npc_used_drugs",
"cashMin": -25,
"base": -1.3, "pressure": 0.9, "risk": 0.3,
"dependence": "drug_dependence", "dependenceGain": 8, "dependenceWeight": 1.8,
"behaviors": { "substance_use": 0.9, "impulsivity": 0.3 },
"effects": { "mood": 12, "energy": -6 }
}
]
}
| Field | Meaning |
|---|---|
id |
Reusing an id replaces that choice. |
history |
The history event recorded (default npc_<id>; "" records none). |
base, pressure, risk, needBonus |
Score: base + pressure × their debt/homelessness pressure + risk × the world mode's risk + needBonus when short of money. |
behaviors |
Personality behavior biases and their weights. |
requiresNeed |
Only when they are short of money. |
gender, targetGender |
"male" / "female" for who may choose it and with whom. |
cashMin, cashMax |
Money gained (positive, a random amount in the range) or spent (negative; never chosen without it). |
targetPays |
Someone at the same place pays it (they must have it). Any of targetPays, sex or targetEffects makes the choice need that person. |
sex |
"consensual" or "forced": goes through the npcSex rules (condom, conception, STD, history). |
dependence, dependenceGain, dependenceWeight |
A stat this choice feeds, and how much that stat pulls them back to it: addiction is a loop. |
drive, driveMinimum, driveWeight |
A stat that has to be high enough, and pushes harder the higher it is. |
effects, targetEffects |
{ "stat": delta } on them and on the other person. |
People who come and go
A room is not a photograph. While the player stands in a venue, the room keeps time (ABirdviewPawn::ReconcileRoomPeople, every few real seconds):
- Arrivals. Anyone whose day now brings them here (their routine, their schedule, their home or work in this building) walks in by the street door and joins the room's life. The player's partner comes home in the evening; the children come back from school; a regular turns up at the bar.
- Departures. Anyone whose day now takes them elsewhere walks to the door and leaves. Someone talking with the player, in a scene, or serving at the counter waits until they are free.
- Strangers turn over on their own: every
turnover_minutesof game time (Animations/ambient.json,arrivals) one leaves and, below the room's cap, a new one arrives. Homes have no strangers (home_tags). - Capacity.
Map/venue_capacity.jsonsays how many people a venue of each tag holds (default10). A routine that sends someone to a full venue is refused (AI:goTofails that script and the next one runs: they do something else), and a loaded room past its cap sends an arrival away the same way. Today a tag is every venue of that kind in the city, so the cap is per kind of venue, not per door.
The other rooms
People use the venue's other rooms (GENERATE.ROOMS of the recipe): the room move of room life sends someone to the bathroom, the bedroom, the toilets, the kitchen for a few minutes of game time, by the rooms block of Animations/ambient.json:
json"rooms": {
"bathroom": { "weight": 3, "minutes": [8, 25], "purposes": ["bathe", "toilet", "change"], "private": true, "adult_lock_chance": 0.35 },
"kitchen": { "weight": 3, "minutes": [5, 20], "purposes": ["cook", "snack"], "private": false }
}
A purpose that names an activity (bathe, toilet) is played there; any other word is only what the event below reports. A private room has a door state: shut, or locked. An adult locks it adult_lock_chance of the time. A minor in a private room always locks it: this is an engine rule (ALifeRoom::IsDoorLocked), a mod cannot change it, and nothing about what is behind that door is ever said. A click on a locked door raises doorLocked and goes nowhere.
An unlocked door opened on someone doing something private raises walkedIn: the engine plays the occupant honestly (the shower keeps running); what the two make of it is mod content (ul_OpenDoors ships the first scenes).
luaEvents:on(M, "walkedIn", function(who, onWhom, room, purpose, venue)
-- who opened the door of `room` ("bathroom") where onWhom was `purpose` ("bathe", "toilet",
-- "change", "rest"...). Ids as strings; the player's id when the player is one of them.
end)
Events:on(M, "doorLocked", function(room, occupant, purpose)
-- the player tried a locked door; occupant may be "" when nobody known holds it
end)
Footsteps
Everyone who walks makes a step every stride_cm (footsteps in ambient.json), played from where they are with distance attenuation: someone crossing behind you is loud, someone at the far end of the bar is faint. files are globs under any mod's Audio/ folder (Steps/hard_*.wav); soft_files play in the rooms and venues named by soft_rooms (carpet). Ship your own set in your mod's Audio/Steps/ and name it in your ambient.json.
City levers
The simulation reads a few multipliers from globals any mod can set with Game:setGlobal (1 when unset, clamped to 0-10). They act from the moment they change. ul_CityHall's policies write them; another mod (a disaster, a festival, a strike) can too.
| Global | What it multiplies |
|---|---|
city_rent |
the rent everyone pays, and what a home costs when someone looks for one |
city_wage |
income from work |
city_jobs |
the chance an application gets a job |
city_healthcare_cost |
the price of treatment for an illness |
city_disease |
the chance of catching an illness |
city_shelter |
divides deaths from exposure and starvation on the street |
city_police |
lowers (above 1) or raises (below 1) the risk bias of every risky choice |
Checking your scenes
From the project, python Tools/SocialSimulation/audit_scene_compatibility.py lists every scene, what blocks it for NPCs and which libraries it needs. -NpcSceneRun=a,b runs chosen scenes twenty times between two NPCs, with -NpcSceneGlobals=key=value;key=value setting story globals first ({A} and {B} stand for the two NPCs' ids), for scenes that need a state of the world to run. The -NpcSceneSweep harness then runs every candidate for real, and -NpcSoak -SoakDays=150 on the world map lets a hundred people live a season and reports what they did.