Skip to content

Lua scripting

Packs can ship per-girl Lua scripts to drive custom encounters, dialogue, and branching events. This page covers:

  • How scripts are wired into a pack
  • The script lifecycle (init and run)
  • Where to find the API reference

For editor setup (VS Code, JetBrains, Neovim, etc.) see editor-setup. The short version: install the sumneko Lua extension, open the game folder as a workspace, and LuaLS picks up resources/scripts/definitions/wm.lua automatically.

Scripts are per-girl. Inside your pack:

MyPack/
package.xml
Girls.girlsx
Characters/
Jane/
Profile/
portrait.jpg
triggers.xml <-- maps events to script files
MeetGirl.lua <-- script referenced by triggers.xml

triggers.xml tells the engine which .lua file to run for which event. A minimal version:

<Triggers>
<Trigger Type="Meet" Where="Town" File="MeetGirl.lua" />
</Triggers>

Trigger types the loader accepts:

Type Fires when
Meet Player encounters the character in the game world
Talk Player clicks “Talk” on the character’s details screen
Stat A named stat reaches a threshold (see below)
Skill A named skill reaches a threshold
Status The character has a particular status (slave, pregnant and so on)
Random May fire on any week
WeeksPast A number of weeks have passed while the character is employed
Shopping The player goes shopping
Money The character’s money reaches a value
PlayerMoney The player’s gold reaches a value
GlobalFlag A global flag is set
ScriptRun A named script has been run
Kidnapped The character is kidnapped

Every name above is spelled exactly as the loader expects it, and an unrecognised Type is logged and the trigger skipped. Only Meet is exercised in the bundled samples, so treat the rest as working but lightly travelled: test your trigger in game and check gamelog.txt if it never fires.

File is required on every trigger. Chance is a percentage and defaults to 100 when omitted. OnceOnly="true" makes the trigger fire at most once.

A Stat trigger names the stat and the value it has to reach:

<Trigger Type="Stat" Stat="Age" Threshold="30" File="TurnsThirty.lua" OnceOnly="true" />

Stat takes a stat name, not a number, and an unknown name is logged and the trigger dropped. The valid names are the same ones effects-reference lists, including Age. Threshold is required.

Skill works the same way with a skill name.

Confirmed Where values (scoping the trigger to a location):

Where Context
Town Encountered walking the streets
Brothel Inside a brothel
Dungeon In the player’s dungeon
Catacombs Exploring the catacombs

In shipped content, only Meet + Where="Town" is used. Other combinations are recognised by the engine but not exercised in the bundled samples.

The engine calls init() once when the script starts, then run() repeatedly until run() returns false.

function init()
math.randomseed(wm.time())
stage = "start"
return true
end
function run()
if stage == "start" then
wm.message("You see " .. wm.girl.name .. " at the market.", 0)
-- ... branching logic
stage = "next"
return true
end
return false -- end the script
end

Returning true from run() means “keep running, call me again”. Returning false (or omitting the return) ends the script.

Before init() runs, the engine sets these globals:

Global What it is
wm.girl The girl involved (table with stats, skills, traits, name)
wm.player The player (table with stats, skills, gold)
wm.area Town encounter area: "market", "slums", "docks", "redlight"
wm.is_dungeon true if the encounter is in the dungeon

Not every global is set in every context. wm.area is only set for town encounters; wm.is_dungeon is only set for dungeon events.

The API covers messages, menus, stat/skill modification, trait add/remove, image display, inventory operations, gold transfers, moving girls between buildings, and pregnancy/child creation. Full list with types and descriptions is in resources/scripts/definitions/wm.lua.

Four functions expose the live trait modifier cache (the same data behind <effect type="…"/> in .traitsx):

  • wm.get_base_stat(name): raw base value, no trait contribution.
  • wm.stat_effect(name): only the trait contribution. effective = base + stat_effect.
  • wm.get_modifier(key): sum of <effect type="modifier"> contributions for key. Use this to read custom flags traits set for your scripts (e.g. if wm.get_modifier("stage_fright") > 0 then ...).
  • wm.add_temp_trait(name, weeks): grants a temporary trait that expires after weeks turns. Returns false if the girl already has it or the name is unknown. Cache rebuilds immediately, so stat_effect / get_modifier reflect the change in the same run() tick.
  • wm.add_trait(name): grants a permanent trait. Returns true when it was added, and false when the character already carries it or the script has no character in context.
  • wm.remove_trait(name): removes a trait. Returns true when one was actually removed.
  • wm.girl_has_trait(name): returns true when the character carries the trait.

These act on the character the script is running against, so a trigger you ship in your pack changes that one character. There is no call that grants a trait to everyone at once. If you want a rule that follows a trait around to every character carrying it, that is what the <OnGenerate> and <Periodic> rules in traits-reference are for, though those inflict state flags rather than traits.

Recipe: grant a trait when a character reaches an age

Section titled “Recipe: grant a trait when a character reaches an age”

triggers.xml:

<Triggers>
<Trigger Type="Stat" Stat="Age" Threshold="30" File="TurnsThirty.lua" OnceOnly="true" />
</Triggers>

TurnsThirty.lua:

function init()
end
function run()
if not wm.girl_has_trait("Mature") then
wm.add_trait("Mature")
wm.message("She notices the first grey hair and says nothing about it.", 0)
end
return false
end

OnceOnly="true" matters here. Without it the trigger stays armed and re-fires every time the condition is checked, and while wm.add_trait returns false rather than duplicating the trait, the message would repeat.

The same shape works for any stat, so Fame, Obedience or Beauty crossing a line can hand out a trait just as well as Age can.

  • wm.create_named_girl{name=..., package=..., global=...}: deep-copies a named unique-girl template into a fresh Girl and returns her as a Lua table (same shape as wm.create_random_girl). name is the template identity (matches Name= on the <Girl> element in a .girlsx, NOT her runtime m_Realname). package is optional; pass the pack folder name (e.g. "Alpha") to disambiguate when two packs ship a template with the same name. Omit package for first-match across all packs. global=true adds her to the global girl pool so the rest of the engine (market filters, etc.) treats her as live. Returns nil if no template matches.
-- Example: a genie-lamp script that spawns a specific bottled spirit
local g = wm.create_named_girl{ name = "Jeanie", package = "ArabianNights", global = true }
if g then
wm.add_girl_to_brothel(g)
wm.message("With a puff of smoke, Jeanie appears at your door.", 0)
else
wm.message("The lamp is silent. The bottled spirit could not be found.", 1)
end
  • wm.give_player_random_special_item(): picks a random item with rarity Shop05 or rarer (i.e. Shop05, Catacomb15, ScriptOnly, ScriptOrReward, Catacomb05, Catacomb01) from the loaded item pool and adds it to the brothel-wide inventory. Returns the granted item’s name on success, or nil if no candidate items exist or the brothel inventory is full – inspect the return value if you want a custom failure narrative.
-- Example: a genie-lamp consumable that grants one rare item
local name = wm.give_player_random_special_item()
if name then
wm.message("The smoke clears -- you find a " .. name .. " in your hand.", 0)
else
wm.message("The lamp sputters. Nothing happens.", 1)
end

Three functions let scripts read and change the player’s disease state. All are pure state operations: no message is emitted automatically, so the script is responsible for any text shown to the player.

Valid disease names: "AIDS", "Chlamydia", "Syphilis", "Herpes".

  • wm.player_has_disease(name): returns true if the player currently carries the named disease, false otherwise.
  • wm.player_add_disease(name): infects the player with the named disease. Returns nothing.
  • wm.player_cure_disease(name): clears the named disease from the player. Returns nothing.
-- Example: react to the player having AIDS, then cure via a magical item
if wm.player_has_disease("AIDS") then
wm.message("She senses something wrong. She hands you a glowing vial.", 0)
wm.player_cure_disease("AIDS")
end

If the API doesn’t expose something you need, open an issue. New exposures are usually cheap to add if the engine already supports the underlying behaviour.

  1. Validate the pack first: tools/pack-validator/pack-validator.exe. It catches XML errors in triggers.xml and flags missing script files.
  2. Load the pack in-game. Scripts compile lazily on first trigger, so errors show up when the trigger fires, not at game start.
  3. Watch gamelog.txt next to the game executable; Lua syntax errors and runtime errors are printed there with line numbers. wm.log("...") calls also go to this file.

See snippets/MeetGirl.lua (a town encounter), snippets/TalkGirl.lua (a custom Talk conversation for one character; the game’s default conversation stays in place for everyone else), and snippets/triggers.xml in this kit for pack-flavoured starters. For a fuller example, copy resources/scripts/templates/MeetGirl_template.lua and adapt it, or read resources/scripts/DefaultInteract.lua, the table-driven conversation the Talk button runs by default.

Real in-game scripts live under resources/scripts/ and in the Character folders of the shipped legacy content. MeetTownDefault.lua is the default fallback when a girl has no MeetGirl.lua of her own, and is a good reference for how the engine drives a multi-stage encounter.

  • wm.log("checkpoint A") prints to gamelog.txt without interrupting gameplay.
  • wm.message("...", 0) prints to the message panel in-game. Good for visible trace points.
  • print(...) also works and routes through wm.log.
  • Long-running scripts that never return false will block the UI. If your encounter freezes the game, check that every code path eventually returns false.