Lua scripting

People

Finding, creating and changing people from a script, their stats, their memory, and their relationships.

Every person a script touches, the player included, is a person handle: Player, Actor, and whatever the lookup functions return. They all have the same methods, listed in the Actor reference.

Finding people

Call Returns
Game:GetPlayer() The player (also passed to RUN as Player)
Game:getCompanion() Who the player is with right now
Game:getTarget() The person the player clicked to start an interaction
Game:getSpecific(role) Someone the player has that relationship with (see below)
Game:getRelative(type) A family member: "Parent", "Child", "Sibling", "GrandParent", "GrandChild", "Cousin", "StepParent", "StepChild", "StepSibling", "ParentSibling" (uncle/aunt), "SiblingChild" (nephew/niece), or nil for any. The same names work in isRelativeType and setRelativeType
Game:getRelativeExcept(type, …) A relative who is none of the listed types
Game:getPerson(tag) The person marked with Actor:setActorVar(tag, true) (a storyline's NPC)
Game:getPerson() A random known person
Game:getPersonHere(permanent) Someone at the player's location: nil anyone, true known people only, false strangers only
Game:getRelatedPeople(type, …) A list of people who share that relationship with each other (a couple, siblings)
Game:getSelectedNPC() The contact picked with Game:selectNPC()

getSpecific roles: Dating, DatingFriend, Affair, Spouse, Colleague, Neighbour, Boss, Landlord, PersonalTrainer, Ex, Companion, plus Lecturer, Student, Coursemate, Fraternity, Employee, Prostitute, Criminal, CrimeBoss, Impregnated, CurrentBabyDaddy, PassedOn, a job title ("Doctor"), or a numeric id from Actor:getID(). If the person is not in the world at the moment, they are brought in with their saved appearance.

Always check isValid()

A lookup that finds nobody does not fail: it returns an invalid person, on which every method is harmless and isValid() is false.

lualocal boss = Game:getSpecific("Boss")
if not boss:isValid() then
	return false       -- no boss: this scene is not for this player
end

People without a body: People

Every lookup above brings the person into the world when they are not there: a scene needs a body to stage. A library, a Mods page or an Events handler usually does not: listing a diary, or calling a plan missed at 3 a.m. while the player is across town, must not spawn anyone, nor bring back someone who was blocked or deleted. Use a reference instead:

lualocal p = People:byTag("ul_phone_partner_1")     -- or People:get(saved_id), People:contacts()
if p and p:isValid() and p:isContact() then
	p:modifyStat("rapportwithplayer", -3)        -- kept for when they next appear
	p:record("ul_phone_missed", People:player(), "major")
end
  • People:get(id) and People:byTag(tag) return nil (not an invalid person) when nobody known matches: a deleted person is gone.
  • A reference reads and writes the same things an Actor does (stats, getVar/setVar, tags, history) and answers the Actor spellings (isValid, GetStat, ModifyStat, isSameAs, isContactExchanged...), so library functions written for an Actor accept one. isValid() means known, not spawned; isLive() says whether they have a body.
  • p:actor() is their Actor only if they already have a body, else nil. When a scene really needs them on stage, use Game:getPerson(tag) there.
  • Keep ids, not references: save p:id() in a variable and People:get it back. The id is the same one Actor:id() gives.

Creating people

lualocal stranger = Game:generatePersonTemporary(true, "athletic")    -- a man, blended with a preset
local rival = Game:generatePerson(false, "age_groups/twenties, athletic_F")
rival:setName("Chloé", "Martin")
  • Game:generatePerson(isMale, presets) creates a permanent person: they are added to the people the player knows, saved, and can be found again later.
  • Game:generatePersonTemporary(isMale, presets) creates someone for this scene only.
  • presets is a comma-separated list of character presets blended onto a random appearance. It may be nil.
  • New people get a random name from the city's name lists; setName(first, last) replaces it.
  • Game:setRaceGenerated("asian") makes everyone generated afterwards in this scene that ethnicity (arab, asian, black, indian, latin, white).
  • Actor:generatePersonMatchRace() / generatePersonTemporaryMatchRace() create someone of Actor's ethnicity.
warning

Always pass the gender explicitly. The legacy forms generatePersonTemporary() and generatePersonTemporary("preset") still run, but today the first one always creates a woman and the second always creates a man and ignores the preset. This will be fixed; the two-argument form is correct either way.

Temporary and permanent

Call Effect
Actor:isTemporary() Not (yet) someone the player knows
Actor:makePermanent() Keep them: they join the known people and are saved
Actor:delete() Remove them from the scene (a known person still exists)
Actor:saveAndDelete() makePermanent() then delete()
Actor:deletePerson() Erase them from the game for good: relationships, memory, everything

Death

Actor:die(cause, killer) and People:get(id):die(cause, killer) kill a known person for good (never the player): a partner is widowed, the home is vacated, the family stays family, and the history keeps an npc_died line. Actor:die also drops the body where it stands. cause is free text ("stabbed", "overdose"); killer is optional (an Actor, a reference or an id). Unlike deletePerson, the dead stay known:

Call Gives
People:dead() Every known person who has died, most recent first, as references
ref:isDead() Dead (also the stat deceased, 1)
ref:diedAt() Game hours of the death, nil while alive
ref:getVar("death_cause") The cause given to die, or the illness
ref:killer() The reference of whoever die named, nil if nobody

Every death, natural ones included, fires the personDied event.

Graves

The dead lie at one of the city's cemeteries. Real OpenStreetMap cemeteries become cemetery venues, named after them; a city with none still gets one. Each dead person gets a plot there the first time anyone asks, and keeps it.

Call Gives
ref:grave() { building, name, plot }, or nil while alive or with no grave
ref:setGrave(buildingId) / ref:setGrave(false) Moves the grave to that cemetery / no grave at all (ashes scattered)
ref:visitGrave() The player stands at it: counted on the dead person (grave_visits, grave_last_visit), fires graveVisited
People:buried([buildingId] [, knownOnly]) The dead buried at that cemetery (default: where the player is), most recent first; knownOnly keeps the player's own people
People:visitedGrave() Whose grave was visited last

At a cemetery, clicking a headstone shows whose grave it is (name, years, plot) and offers "Stand at the grave", which counts the visit and rolls the scenes whose WHAT lists visit_grave. Those scenes read the person from People:visitedGrave(). Every dead person buried there gets a stone with their name and years, the player's own dead nearest the middle. To send the player to a grave, pin an objective to it with alias:pinTo(ref:grave().building).

Epitaphs. Under a named stone's name and years, the engine engraves one line from Graves/*.ulgraves in any mod: { "generic": ["..."], "by_cause": { "illness": ["..."] } }. The cause is the one the town recorded for the death; one line is picked per person (always the same for a given stone), | breaks a line, and a later mod's file adds to the pools. ul_Base ships a set.

Watching other people's sex

When two NPCs have sex in the room the player is in, the engine plays it for them: one scene of two or three positions, each a different clip ranked by the pair's likes, lengths from ul_Base's autonomy policy (watchedSexStagesMin/Max, watchedSexStageMinSeconds/MaxSeconds). Minors in the room are never on screen: they are hidden and drawn as the shared adult shadow. People:sexSeen() returns { a, b, forced } (or nil); People:interruptSex() stops it. The player's var npc_sex_here is 1 or 2 (forced) meanwhile, so an action can use it in CONDITIONS. ul_Voyeur is the player's side.

Lasting conditions

A limp, a bad back, a chair: something that stays with a person, saved with them and visible to every mod. Conditions are mod data, in Conditions/*.ulconditions (JSON):

json{
	"conditions": [
		{ "id": "limp", "name": "A limp", "pain": 35, "speed": 0.75, "walk": "walk_limp", "sex_avoid": ["Standing"] }
	]
}
Field Does
pain The pain stat never heals below this while the condition is held
speed Walk speed factor; the slowest held condition wins
walk An activity walked instead of walk, when a mod ships one (a limp, a wheelchair); otherwise the walk is only slowed
sex_avoid Sex positions with any of these tags are not offered while the holder takes part
confines Shut in (a cell, a cage, a locked room): an NPC holder does nothing, and the player's actions can test the var confined (STAT = "confined")
starve_days, starve_cause Days without feed() before the holder dies of that cause (an NPC through the town's death, the player gets the playerStarved event)

A later mod redefines an id in place. ul_Base/Conditions/base.ulconditions also holds walk_speed_by_age ([age, factor] points, for everyone: children a little quicker, the old slower), pain_heal_per_day and pain_return_per_day.

Call (Actor or reference) Does
addCondition(id [, cause]) Gives it; false for an id no mod defines or one already held
removeCondition(id) Takes it away
hasCondition(id) Held now
getConditions() { { id, name, text, since, cause, pain, speed, confines, module } }
confine(id [, place [, cause]]) Gives a condition that has confines, with a place a mod reads back
confined() { condition, place, since }, or nil
feed() Restarts the starve_days of the confining conditions

Each change fires the conditionChanged event.

Pain. The pain stat (0–100, ul_Base) is how much the body hurts today. Overnight it heals pain_heal_per_day down to the floor: the highest pain of the held conditions, less the person's pain_relief var (physio raises it, giving up can make it negative). Taken below the floor (pills), it comes back pain_return_per_day a day. A sex scene starts from it and leaves some behind.

Stats

lualocal mood = Player:getStat("mood")
Actor:modifyStat("rapportwithplayer", 10)
Actor:setStat("arousal", 0)

Stats are defined by .ulstat files (Stats); values are kept within the stat's minimum and maximum. modifyStat is scaled by the game difficulty for stats marked HIGHER_BETTER: gains shrink and losses grow on harder settings. Call Game:PreciseModify() once to turn that off for the rest of the scene.

Memory: variables

Each person can hold your mod's own data. It is saved with them.

luaActor:setVar("ynl_times_met", (Actor:getVar("ynl_times_met") or 0) + 1)
Actor:setVar("ynl_nickname", "Red")
Actor:setVar("ynl_nickname", nil)          -- delete it

if Actor:getVar("ynl_times_met") == nil then
	-- never set
end
  • Values can be numbers, text or true/false. getVar returns nil for a key that was never set.
  • Keys are shared by every mod: always prefix them with your mod's id.
  • On NPCs, variables ending in _today, _thisweek or _thismonth are reset to 0 at the start of each day, on Sundays, and on the 1st of the month.

Actor:setActorVar(tag, true) is different: it marks a person so Game:getPerson(tag) can find them later from any scene. Use it to pick "the" NPC of a storyline once and come back to them.

For data that belongs to nobody, use story globals.

Relationships

Questions, all relative to the player:

Call True when…
isDating(), isMarried(), isRelative(), isExRelative() …they are the player's partner, spouse, family, ex
isColleague(), isNeighbour(), isBoss(), isEmployee(), isLandlord() …they have that role
hasRelationship("Spouses", "Dating") …any of up to three roles
isRelativeType("Sibling", "Cousin") …they are one of these kinds of relative
isContactExchanged(), livesWithPlayer(), hadSex() …self-explanatory
isRelationshipWith(other, "Siblings") …they have that relationship with other (NPC to NPC)
isInterestedIn(other), isGay(), isStraight() orientation checks

Changes:

Call Effect
Actor:startDate() / Game:endDate() Start or end a date (they become / stop being the companion)
Actor:setDating() They become the player's partner
Game:getMarried() Marry the current partner
Actor:addColleague(), addNeighbour(), addDatingFriend(), setBoss(), setLandlord(), setPT() Give them that role
Game:exchangeContact(Actor), Game:blockContact(Actor) Add to or block from the player's contacts
Actor:setRelativeType("Cousin") Make them family
Game:addNpcRelationship("Siblings", a, b) A relationship between two NPCs
Actor:makeInterested(Player) Shift their orientation towards someone

Clothes

Call Effect
Actor:dress() An everyday outfit (sportswear at the gym, swimwear at the pool or beach)
Actor:dress("Nice-Jeans_1_F", "Cute-Croptop_2_F") These garments, over what they wear
Actor:dress("sports"), ("swim"), ("work") An outfit from that list
Actor:dressUniform("Police"), dressUniform() A uniform by name, or the one of the place they are in
Actor:dressFormal() Evening wear or a suit
Actor:dressBondage("Handcuffs"), dressBondage() Restraints over the clothes (no name: collar and handcuffs)
Actor:dressExcept("Underwear", …) Dress but leave these categories as they are
Actor:strip(), Actor:stripOne("Top") Undress completely or one category
Actor:isNaked(), Actor:isEquipped("Top") Checks
Actor:getExposure(), Actor:isExposed("genitals") What shows through the clothes: { breasts, genitals, butt, any } booleans. Breasts show when the top (or coat) and the bra are off, ripped or pushed aside; genitals when the bottom is off, ripped open or hiked up and the underwear off, ripped, aside or down past the thighs (a chastity belt covers); the butt likewise, but a panty pushed aside still covers it. A minor never shows anything. The engine raises exposureChanged and exposureSeen (Game-time events) from the same rules
Actor:setNoRedress() Keep this outfit after the scene instead of restoring the previous one

Categories: Top, Bottom, Dress, Outerwear, Underwear, Shoes, Socks, Headwear, Accessory.

An id is a .ulcloth file name; on a woman "Jeans_1" also finds "Jeans_1_F". An id that no mod ships is skipped, and when none of them is found they get an everyday outfit.

Outfit lists: Clothes/Outfits/

Any mod can add to these files in its own Clothes/Outfits/ folder:

  • outfits_F.txt, outfits_M.txt, outfits_F_sports.txt, outfits_M_swim.txt, outfits_F_work.txt... : outfits, one block of item ids per outfit, blocks separated by an empty line. Every mod's blocks are added together. A new list (outfits_F_goth.txt) is used by dress("goth").
  • uniforms.txt: blocks of four lines: the name, the venue tags it is worn at, the women's outfits (separated by ;, items by ,) and the men's. A later mod's block of the same name replaces it. The engine also uses these tags on its own: a known NPC who appears at their own workplace during their shift wears the first uniform whose tags include that venue's tag (that uniform is not saved as their everyday clothes).
textpolice
police, prison
Uniform-Police_1_F, Uniform-Police-Cap_1_F, Jill-Valentine-Boots_1_F
Uniform-Police-Top_1, Uniform-Police-Pants_1, Uniform-Police-Cap_1, Urban-Ace-Shoes_1
  • restraints.txt: name = alternative, alternative, one alternative picked at random; + joins the pieces of one alternative (default = Bdg_Collar + Bdg_Handcuffs).

Appearance

Call Effect
Actor:blendPreset("athletic") Blend a preset onto their current look
Actor:setMorph(name, value), modifyMorph(name, delta), getMorph(name) Reshape the body or face over time (below)
Actor:copyAppearanceFrom(other), restoreAppearance() Wear someone else's look, and get yours back
Actor:setMorphValue(name, value), getMorphValue(name) One Genesis 8 dial, raw
Actor:randomizeFace(), randomizeHairs(), randomizeRace() Re-roll parts of their look
Actor:blendAppearanceFrom(a, b) A child's face from two parents
Actor:isEthnic("asian"), isSameEthnic(other) Ethnicity (arab, asian, black, indian, latin, white)
Actor:getRace(), setRace("Elf"), isRace("Orc"), isSameRace(other) Fantasy race: Human (default), Elf, Orc, Vampire

Changing the body

A diet, a gym habit or a surgeon reshapes someone with named shapes, so your mod never needs Genesis 8 dial names:

luaPlayer:modifyMorph("weight", -0.05)   -- a month of dieting; -1 thin .. 1 heavy
Player:modifyMorph("muscle", 0.03)
Player:setMorph("nose", 0.2)          -- surgery
if Player:getMorph("weight") > 0.6 then ... end

The shapes are listed in ul_Base/CharGen/MorphAliases.txt (weight, muscle, bodybuilder, tone, breasts, implants, pectorals, waist, belly, hips, butt, thighs, calves, shoulders, arms, nose, lips, jaw...); a mod adds its own there (see Character creator). Any other name is taken as a dial. Changes are saved, for the player and for anyone they know.

copyAppearanceFrom(other) gives someone another person's whole look (face, body, hair, skin, clothes) while they stay who they are; restoreAppearance() gives theirs back. Two calls swap two people:

luaPlayer:copyAppearanceFrom(Stranger)
Stranger:copyAppearanceFrom(Player)   -- gets the player's real look, not the borrowed one
-- ... later
Player:restoreAppearance()
Stranger:restoreAppearance()

Where they are

Call Effect
Actor:getBuilding("Home") / "Work" / "Current" A venue tag
Actor:isAt("Home") / "Work" Whether they are there right now
Actor:addToPeopleHere() Keep them at the player's location after the scene
Actor:addScheduleEntry(from, to, weekdays, tag) A rule in their weekly schedule (weekdays is a bitmask, 127 = every day, bit 0 = Sunday)
Actor:clearSchedule() Drop all their schedule rules

Day-to-day movement of known people is driven by NPC routines.

Injuries

A fight leaves marks. Actor:addInjury(name, severity, days) paints a bruise or cut over the skin and fades it out over the days; healInjury(name?) takes it away (all of them without a name), hasInjury, getInjuries read them.

luaVictim:addInjury("black_eye_left", 0.9, 6)
Victim:addInjury("split_lip", 1, 3)
if Player:hasInjury() then Game:Narrate("The nurse frowns at your face.") end

ul_Base ships black_eye_left/right, cheek_bruise_left/right, split_lip, forehead_cut (Face), ribs_left/right, stomach, chest (Torso), forearm_left/right, upper_arm_left/right, wrists (Arm), thigh_left/right, shin_left/right (Leg). A mod adds its own as Injuries/<Face|Torso|Arm|Leg>/<name>.png: a 1024 RGBA image in that skin's UV layout (the same as tattoos and makeup), dark colour with soft alpha. Injuries are part of the look, so they are saved.

Makeup and tears

Actor:setMakeup(zone, style, r, g, b, a) puts one layer on the face: Eyeshadow, Blush, Liner, Lips or Tears, a style from Makeup/<zone>/, a colour and a strength. Actor:setTears(strength) runs mascara down the cheeks; setTears(0) dries it. Both are part of the look and saved.

luaVictim:setTears(0.9)
Date:setMakeup("Lips", "LK_Lips_02", 0.5, 0.05, 0.1, 0.85)

A mod adds styles as Makeup/<Eyeshadow|Blush|Liner|Lips|Tears>/<name>.png: white, the shape in the alpha channel, on the Genesis 8 face UV (the same layout as injuries and Makeup/Face/ looks). The game tints it with the zone's colour. Every style of the four creator zones (Eyeshadow, Liner, Blush, Lips) is listed on the creator's Makeup page under its own heading, with a colour palette, from its Thumbs/<name>.png; Tears stay a scene effect. Makeup/makeup.json sets how often generated women wear each zone and in which colours:

json{ "zones": { "Lips": { "chance": 0.5, "strength": [0.45, 0.85], "colors": [[0.55, 0.12, 0.14], [0.8, 0.45, 0.45]] } } }

A later mod's file replaces the zones it names.

The party

Up to four people can walk with the player: a bodyguard, the crew on a job, a rescued relative brought home. They are wherever the player is and every room places them, with no show() needed.

luaif Actor:joinParty() then
	Game:Narrate("<Actor.name> falls in beside you.")
end
for _, id in ipairs(Game:getParty()) do ... end
Actor:leaveParty()

joinParty() returns false when the party is full or the person is already in it. Each member has a chip under the player's bars on the city map: the name opens their sheet, the house sends them home (their sheet has "Send home" too). An action with END_PARTY = true (sleep) breaks the party up.

History

People remember what happened to them. See History.

luaActor:record("argued", Player, "major")
if Actor:hasEvent("argued") > 2 then
	Game:Say(Actor, "Angry", "Not this again.")
end

Methods added by mods

Mods can add methods to every person (see Libraries). With ul_Health enabled, for example, everyone has infect, injure, treat, isSick and condition:

luaif Game:isModEnabled("ul_Health") and Actor:isSick() then
	Game:Say(Actor, "Sad", "Sorry, I feel awful today.")
end