Lua scripting

State

The variables a mod owns, declared once with a type and a scope, read from Lua and from scene conditions by name.

warning

Format in progress, engine in build (2026-10-25; world vars and maps 2026-10-26). The keys are the vocabulary of Tools/ModSchemas/formats/ulstate.schema.json; the file is a Lua table. getVar / setVar / setActorVar keep working during the migration.

Today a mod's state is a bag of strings with a prefix by convention (setVar("ul_db_owed"), setActorVar("tag_ul_dpl_partner")): no owner, no type, nothing cleared when the mod is turned off, and vars copied into stats so that CONDITIONS can read them. Now a mod declares its variables; the engine types them, shows them in the debug sheet, resets or drops them with the mod, and logs an access to one that was never declared.

The file

State/*.ulstate, any number per mod. ul_Debts/State/debts.ulstate:

luareturn {
	scope = "person",
	vars = {
		press_next = { type = "time",  default = 0, doc = "Game hour before which the collectors leave the player alone." },
		owed_total = { type = "money", default = 0, min = 0, doc = "Mirror of everything owed, for conditions and HUDs." },
		lender     = { type = "person", default = nil, doc = "Who they borrowed from." },
		snubbed_today = { type = "bool", default = false, reset = "daily" },
	},
}
Key Means
scope Default scope of every var in the file: "person" (on each person, the player included), "pair" (a value A holds about B: a debt of A towards B, a grudge, a promise) or "world" (one value for the town; replaces the prefixed worldGlobals). A var may set its own.
type "number" (default), "int", "money", "bool", "time" (a game hour), "person" (a person id), "text".
default The value before anything wrote it.
min, max Clamped on write.
reset "daily": zeroed every midnight (person scope). Replaces the _today naming convention.
decay A number: every midnight the value moves that much toward its default, never past it (hook = { type = "int", default = 0, decay = 2 } loses 2 a day until it is back at 0). Any scope (a world var moves once, on the town's midnight pass); not with reset.
map true: one value per key (a policy, a ballot slot, a person id) instead of one value; read with State.mapGet (below).
doc What it is, for the reader and the debug sheet.

A "text" var reads "" when nothing wrote it (the engine stores an unset value as 0, and a text that looks like a number as that number: "5" comes back "5", an id stays an id).

A state var is not a stat. Stats are the values of a person the player sees, with a UI, difficulty and decay, declared in .ulstat. A var is the mod's own bookkeeping; a condition can read it directly, so no mirror stat is needed.

From Lua

The State global is there in every library and scene, scripted scenes included:

luaState.get(p, "ul_Debts", "owed_total")        -- a person var (p: an Actor or a People reference)
State.set(p, "ul_Debts", "lender", lender)
State.add(p, "ul_Debts", "owed_total", 500)    -- clamped to min/max
State.clear(p, "ul_Debts", "lender")          -- back to the default

State.pairGet(a, b, "ul_Debts", "grudge")     -- what a holds about b
State.pairSet(a, b, "ul_Debts", "grudge", 3)
State.pairAdd(a, b, "ul_Debts", "grudge", 1)

State.get(nil, "ul_Debts", "book")            -- a world var takes no person
State.add(nil, "ul_Debts", "book", 200)

State.mapGet(nil, "ul_CityHall", "demand", "police")   -- a map var: one value per key
State.mapAdd(nil, "ul_CityHall", "demand", issue, 1)
State.mapSet(nil, "ul_Politics", "cand", slot, id)

State.decl("ul_Debts", "owed_total")          -- the declaration { type, default, doc… }, for HUDs

The mod is named every time: the engine checks the var is declared and typed, and another mod reading yours is visible as such. Undeclared vars are logged (an error in development, the default value in release); the validator refuses them before that (ModLint L109, which also catches mapGet on a var without map = true and the other way round).

World state: what the town shares

A world var is one value for the whole town, and the same value in the player's scenes and in the NPC scenes (NPC life): an NPC scene reads the game's own value and its writes are published when the scene succeeds. Put them in their own file, State/world.ulstate:

luareturn {
	scope = "world",
	vars = {
		book      = { type = "number", default = 0, doc = "Everything the town owes the loan sharks, all told." },
		borrowers = { type = "int",    default = 0, doc = "Townspeople who borrowed from them." },
		demand    = { type = "number", default = 0, map = true, doc = "Per issue: how loud the town is about it." },
	},
}

Until 2026-10-26 this was worldGlobals in a .ulsocial: a list of prefixes ("db_npc_"), and every global starting with one was shared. The prefixes also caught other mods' keys (ul_CityHall's ch_ made ul_Chronicle's log shared by accident) and said nothing of what each value was. The 35 official mods that used it declare their State/world.ulstate now, and the validator refuses worldGlobals (ModLint L110). A world var is stored as the global "<mod>.<name>" ("<mod>.<name>:<key>" for a map), so a save from before the change does not carry those values over.

From a scene

Conditions and effects name a var as "<mod>.<name>" (Scenes as tables):

lua{ var = "ul_Debts.owed_total", op = ">", value = 0 }
{ var = "ul_Debts.press_next", set = "now" }
{ var = "ul_Debts.owed_total", who = "target", amount = -100 }
{ var = "ul_Debts.lender", set = "collector" }

set takes a value, "now" (the current game hour, for a time var) or a role name (for a person var). An action's conditions read vars the same way.

What it removes

Prefixes by hand, mirror stats, _today by name, and the tag_* marks on people (a pair var, or a person var, says the same thing with a type). The migration converter (Tools/ModMigrate/) lists every getVar / setVar / setActorVar of a mod and proposes its State/*.ulstate with guessed types, to be read before it is kept.