Skip to content

NPC dialogue

⚠️ Partial

NPCs speak through a data-driven dialogue bank. Each line is authored as plain JSON, grouped by the NPC’s personality and by a category (idle chatter, capture taunts, command replies, and so on). At runtime the game looks up a dialogue id for the speaker’s personality, filters the matching entries by context, then picks one weighted variant to display.

This page documents every field the game actually reads when loading the dialogue bank.

data/tiedup/dialogue/<lang>/<personality>/<category>.json
^^^^^^ ^^^^^^^^^^^^^ ^^^^^^^^^^^
locked fixed list fixed list
SegmentAllowed valuesNotes
<lang>en_usOnly en_us is ever read. Other lang folders are ignored.
<personality>one of the 11 personalities, or defaultSee Personalities. default is the fallback bank merged into every personality.
<category>one of the fixed category namesSee Categories. A filename not on the list is silently skipped.

A category file is one JSON object with an entries list. Each entry has an id, optional conditions, and a list of weighted variants:

{
"category": "idle",
"personality": "TIMID",
"description": "Timid idle behaviors and greetings",
"entries": [
{
"id": "idle.greeting",
"variants": [
{ "text": "*waves shyly*", "weight": 10, "is_action": true },
{ "text": "H-hello...", "weight": 10 },
{ "text": "*avoids eye contact* Oh, um, hi...", "weight": 8 }
]
}
]
}
FieldTypeRead?Notes
entriesarrayyesThe only field the game consumes. A file with no entries array loads zero lines.
categorystringnoMetadata / human label only. The real category is the filename — this key is never read. Keep it accurate for your own sanity.
personalitystringnoMetadata. The real personality comes from the folder name, applied automatically as a condition.
descriptionstringnoFree-text comment for authors.
FieldTypeReq?Notes
idstringyesThe lookup key the game asks for, e.g. idle.greeting, command.follow.accept. An entry with no id is dropped.
variantsarrayyesAt least one valid variant, or the entry is dropped.
conditionsobjectoptionalExtra context filter on top of the folder’s personality. See Conditions.

An entry with the same id may appear in several files. Personality-specific entries take priority over the default bank — when a personality folder defines an id, its variants are tried first, with the default bank as fallback.

FieldTypeDefaultNotes
textstringRequired. The line shown (after variable substitution). A variant with no text is skipped.
weightint10Relative weight in the weighted-random pick. Higher = more frequent.
is_actionboolfalseIf true, the line is treated as a roleplay action: it is wrapped in *asterisks* and is exempt from gag muffling.

conditions narrows when an entry is eligible, in addition to the personality implied by the folder. Only three keys actually filter — the rest are accepted for backward compatibility but have no effect.

{
"id": "mood.sad",
"conditions": { "mood_max": -20 },
"variants": [
{ "text": "*sighs heavily*", "weight": 10, "is_action": true },
{ "text": "I miss being free...", "weight": 10 }
]
}
KeyTypeEffect
personalitystring[]Restrict to these personalities (names case-insensitive). Ignored inside a personality folder — the folder already pins the personality; it only matters in the default / speaker-type folders. Unknown names are skipped — you’ll see a warning in the log.
mood_minintEntry eligible only when the speaker’s mood ≥ this. Mood ranges roughly -100…100.
mood_maxintEntry eligible only when the speaker’s mood ≤ this.

The 11 personality folders are a fixed list you cannot extend. Folder names are the lowercased personality names:

timid gentle submissive calm curious proud
fierce defiant playful masochist sadist

Plus the special default folder, whose lines are merged into every personality as a fallback. You cannot add a 12th personality from a pack — a folder named anything else is never scanned.

The category is the filename (without .json). There is one fixed set of valid category names, and any of them is scanned in every personality folder and every speaker-type default/ folder — there is no separate “speaker-only” list. Anything not in this set is ignored:

commands capture struggle jobs mood combat
needs idle actions fear reaction environment
discipline home leash resentment personality
conversation punishment purchase inspection petplay
guard_labor maid_labor guard dogwalk
patrol punish

So data/tiedup/dialogue/en_us/timid/punishment.json and data/tiedup/dialogue/en_us/master/default/idle.json are both scanned — the same names apply everywhere.

  1. The game asks for a dialogue id (e.g. idle.greeting) for a speaker with a known personality.
  2. It gathers every entry registered under that id for that personality — personality-specific first, then the merged default bank.
  3. It drops entries whose conditions don’t match the current context (personality + mood).
  4. From the first matching entry it picks one variant by weighted random.
  5. Variables in the text are substituted, action text is wrapped in *…*, and if the speaker is gagged, non-action speech is muffled.

If no entry matches, the bark is skipped (the caller may supply its own hard-coded fallback).

  1. Create the file at the right path — defiant personality, capture category:
    data/tiedup/dialogue/en_us/defiant/capture.json
  2. Write the entry. The folder already pins DEFIANT, so no personality condition is needed:
    {
    "category": "capture",
    "personality": "DEFIANT",
    "description": "Defiant reactions while being captured",
    "entries": [
    {
    "id": "capture.start",
    "variants": [
    { "text": "You'll regret this!", "weight": 10 },
    { "text": "*thrashes against your grip*", "weight": 10, "is_action": true },
    { "text": "I will NOT go quietly!", "weight": 8 },
    { "text": "*spits* Coward.", "weight": 4 }
    ]
    }
    ]
    }
  3. Run /reload. The log reports how many dialogue ids loaded. A malformed file is skipped — you’ll see a warning in the log, and the rest still load.