Skip to content

NPC & Quests Creation

Myroslava Saveniuk edited this page Oct 1, 2026 · 1 revision

Last verified: 01.10.2026 | Game version: S.T.A.L.K.E.R.: Call of Pripyat (Steam, patch 1.6.02) | OS tested: Debian (Linux)
Status: Active — full NPC + two-stage quest pipeline verified end-to-end

TL;DR

Creating a custom NPC with a quest always involves the same core pipeline:

  1. Register the NPC — three config files tell the engine who the NPC is
  2. Write a logic file — tells the NPC what to do (stand still, wander, etc.)
  3. Spawn the NPC — via Lua script, not all.spawn
  4. Wire a dialog — see Communication Text
  5. Register the quest — one section in tm_<location>.ltx

Everything below is verified in-game unless explicitly marked [UNVERIFIED].


NPC types

CoP NPCs fall into two behavioral categories depending on whether they have a custom_data field in their spawn section:

Type custom_data Behavior
Stationary / scripted points to a logic .ltx NPC does exactly what the logic file says: stand in place, use animations, open dialog
Community / wandering absent NPC is handed to the squad/smart terrain simulation — wanders to smart terrains, joins firefights, follows faction rules

For quest NPCs, you almost always want a stationary NPC. Without custom_data, the NPC will walk away.

Faction behavior and hostility

community in the spawn section and character_desc controls faction membership but does not make an NPC immediately hostile. Verified behavior:

  • community = stalker — neutral to player, fights only if attacked
  • community = bandit — neutral to player on spawn, fights only if attacked

This matches how real game bandits behave: they're neutral until a fight starts or a script triggers hostility. Faction hostility (bandit vs stalker) applies between NPCs, not necessarily toward the player. For truly hostile NPCs that attack on sight, additional scripting beyond community is required — this is [UNVERIFIED].

terrain_sect is always stalker_terrain for every faction — stalker, bandit, dolg, freedom, army, monolith. There is no bandit_terrain or equivalent. All factions use the same value.


Part 1 — Registering an NPC (3 required files)

Every custom NPC needs entries in exactly these three files. All three must use matching identifiers.

File 1 — configs/gameplay/npc_profile.xml

Links the NPC's class name to its character description.

<character id="my_npc">
    <class>my_npc</class>
    <specific_character>my_npc</specific_character>
</character>

The game ships with one npc_profile.xml. Copy it from stalker_unpacked/configs/configs/gameplay/ to gamedata/configs/gameplay/, then append your entry before </npc_profiles>.

File 2 — configs/gameplay/character_desc_<location>.xml

Defines the NPC's appearance, voice, rank, reputation, inventory, and dialogs.

<specific_character id="my_npc" team_default="1">
    <name>st_my_npc_name</name>
    <icon>ui_inGame2_Sich</icon>
    <bio>Short bio text here.</bio>
    <class>my_npc</class>
    <community>stalker</community>
    <terrain_sect>stalker_terrain</terrain_sect>
    <snd_config>characters_voice\human_02\stalker\</snd_config>
    <rank>40</rank>
    <reputation>0</reputation>
    <money min="500" max="500" infinitive="0" />
    <visual>actors\stalker_neutral\stalker_neutral_3</visual>
    <supplies>
      [spawn] \n wpn_pm \n ammo_9x18_fmj = 1 \n
#include "gameplay\character_items.xml"
#include "gameplay\character_food.xml"
#include "gameplay\character_drugs.xml"
    </supplies>
#include "gameplay\character_criticals_3.xml"
    <start_dialog>my_npc_dialog_id</start_dialog>
    <actor_dialog>actor_break_dialog</actor_dialog>
</specific_character>

Key fields:

  • <rank> and <reputation> — mandatory numeric fields. Omitting them reportedly causes a data()->m_Rank != NO_RANK crash.
  • <name> — a string table id (see Communication Text, not literal text.
  • <visual> — path to the NPC's body mesh, relative to meshes/. Browse stalker_unpacked/ for options.
  • <terrain_sect> — always stalker_terrain regardless of faction.
  • <start_dialog> — the dialog that opens when the player talks to this NPC.
  • <actor_dialog> — extra dialog topics the player can bring up (see Part 4). Always include actor_break_dialog as the last entry.

One file per location: character_desc_zaton.xml, character_desc_jupiter.xml, character_desc_pripyat.xml. Shared NPCs go in character_desc_general.xml.

File 3 — configs/creatures/spawn_sections_<location>.ltx

Defines the spawn section — the "blueprint" the engine reads when creating the object.

[my_npc]:stalker
$spawn             = "respawn\my_npc"
character_profile  = my_npc
spec_rank          = regular
community          = stalker
story_id           = my_npc
custom_data        = scripts\<location>\my_npc_logic.ltx
  • story_id — a unique name used to look up this object from Lua (get_story_object_id("my_npc")). Required for map markers.
  • custom_data — critical. Points to the logic file. Without this line the NPC ignores all logic files and wanders off using default community behavior. This is the most common cause of "NPC runs away."

For a wandering NPC with community behavior, simply omit custom_data. The engine assigns the NPC to the nearest smart terrain automatically.

Encoding: if any of these files contain Cyrillic text (bio, name), they must stay in Windows-1251. Copy the original with Python (open(path, 'rb') → open(dst, 'wb')) to preserve encoding, then append your entry. See Environment & Tools.


Part 2 — Logic file (stationary NPC)

The logic file tells the NPC what scheme to run. For a quest NPC that stands in place and opens dialog, use xr_remark.

Create gamedata/configs/scripts/<location>/my_npc_logic.ltx:

[logic]
active = remark@base

; Prevents smart terrain simulation from overriding this logic.
; Without this the NPC will walk away to the nearest smart terrain.
[smart_terrains]
none = true

[remark@base]
anim              = wait
target            = story | actor   ; MUST include "| actor" — plain "actor" crashes (see Pitfalls)
meet              = meet
danger            = danger_ignore
combat_ignore_cond             = true
combat_ignore_keep_when_attacked = true
invulnerable               = true
gather_items_enabled       = false
help_wounded_enabled       = false
corpse_detection_enabled   = false
use_camp                   = false

[danger_ignore]
ignore_distance = 0

[meet]
close_snd_hello = nil
close_snd_bye   = nil
close_anim      = nil
close_victim    = actor
far_anim        = nil
far_victim      = nil
close_distance  = 0
far_distance    = 0
use          = {=actor_enemy} false, true
allow_break  = false
trade_enable = false

Two rules that are easy to get wrong:

  1. The root section must be [logic], not [logic@my_npc]. The @name + suitable= pattern is only for shared logic files serving multiple NPCs (like Nimble's). A dedicated custom_data file always uses plain [logic].
  2. target = story | actor — the pipe character is required. xr_remark.parse_type searches for | to split the string; if absent it falls through to string.sub(target_str, 1, pos-1) with pos = nil → fatal crash.

Part 3 — Spawning via Lua script

Do not edit all.spawn — it's a binary file, requires external tools, and breaks save compatibility. Spawn custom objects with:

alife():create(section, position, level_vertex_id, game_vertex_id)

Get coordinates from the in-game console: rs_cam_pos 1 prints camera position with all four values.

One-time spawn (persists across save/loads)

actor_binder:net_spawn runs on every load, not just new game. Guard spawns with an info_portion:

if not db.actor:has_info("my_npc_spawned") then
    alife():create(
        "my_npc",
        vector():set(X, Y, Z),
        level_vertex_id,
        game_vertex_id
    )
    db.actor:give_info_portion("my_npc_spawned")
end

Confirmed API:

  • db.actor:has_info("id") — not has_info_portion, despite what you might expect
  • db.actor:give_info_portion("id")
  • Info portions set during a session are persisted in .scop save files

Auto-spawn on new game start

CoP loads Lua scripts lazily — a script only runs when its namespace is first accessed. A new script in gamedata/scripts/ will not load at startup on its own.

The reliable pattern is to copy bind_stalker.script (from stalker_unpacked/configs/scripts/) to gamedata/scripts/ and add a spawn call inside actor_binder:net_spawn, right after db.add_actor(self.object) (the line that sets db.actor):

-- in actor_binder:net_spawn, after db.add_actor(self.object):
my_mod.spawn_entities()

Put the actual spawn logic in a separate script file (my_mod.script) so bind_stalker.script stays clean. bind_stalker.script is guaranteed to load at every game start because the engine loads it as part of the actor binder system.

The spawn script referenced from bind_stalker.script is loaded via my_mod.spawn_entities() — CoP's lazy-load mechanism resolves my_mod the first time that line executes, so no explicit require is needed.


Part 4 — Dialogs: start_dialog vs actor_dialog

CoP has two distinct dialog types with opposite phrase structures.

start_dialog — NPC speaks first

The dialog the NPC opens with automatically. Phrase 0 is the NPC's greeting. Its <next> targets are the player's response options shown in the menu.

<dialog id="my_npc_start">
    <phrase_list>
        <phrase id="0">           <!-- NPC speaks first -->
            <text>npc_greeting</text>
            <next>1</next>
            <next>2</next>
        </phrase>
        <phrase id="1">           <!-- Player choice A -->
            <text>player_option_a</text>
            <next>3</next>
        </phrase>
        <phrase id="2">           <!-- Player choice B -->
            <text>player_option_b</text>
            <is_final>1</is_final>
        </phrase>
        <phrase id="3">           <!-- NPC response to A -->
            <text>npc_response_a</text>
            <is_final>1</is_final>
        </phrase>
    </phrase_list>
</dialog>

actor_dialog — player speaks first

Topics listed as <actor_dialog> in character_desc. Phrase 0 is the player's opening line — this is the text shown as the selectable topic in the conversation menu. Its <next> leads to the NPC's response.

<dialog id="my_npc_topic">
    <phrase_list>
        <phrase id="0">           <!-- Player topic shown in menu -->
            <text>player_question</text>
            <next>1</next>
        </phrase>
        <phrase id="1">           <!-- NPC responds -->
            <text>npc_answer</text>
            <is_final>1</is_final>
        </phrase>
    </phrase_list>
</dialog>

Add as many <actor_dialog> entries as needed — all are shown simultaneously in the menu. After <is_final>, the player returns to the menu and can select another topic. This means actor_dialogs are repeatable by default and can be combined in any order within one conversation — this is intended CoP behavior, the same as Nimble, Owl, and other NPCs in the base game.

Conditional actor_dialog — dialog-level precondition

To show/hide an entire actor_dialog topic based on game state, add <precondition> inside the <dialog> element before <phrase_list>. It takes a Lua function that returns true/false:

<dialog id="my_npc_topic_conditional">
    <precondition>my_script.show_this_topic</precondition>
    <phrase_list>
        ...
    </phrase_list>
</dialog>
function show_this_topic(actor, npc)
    return actor:has_info("my_quest_given")
end

Verified: the dialog disappears from the menu when the function returns false, and reappears when it returns true.

Conditional phrase — phrase-level precondition

Individual phrases also support <precondition>. Use this when you need to hide a specific player choice based on runtime state (e.g. hide "Here's your item" if the player no longer has the item):

<phrase id="40">
    <text>player_deliver</text>
    <precondition>my_script.player_has_item</precondition>
    <has_info>my_item_found</has_info>
    <dont_has_info>my_quest_done</dont_has_info>
    <action>my_script.receive_item</action>
    <give_info>my_quest_done</give_info>
    <next>41</next>
</phrase>
function player_has_item(actor, npc)
    return actor:object("my_item_section") ~= nil
end

Critical: <give_info> in XML fires unconditionally when a phrase is selected, regardless of what the <action> Lua function does. If the action may silently fail (item not in inventory, NPC not reachable), guard with a phrase-level <precondition> — otherwise the info_portion is given even though nothing happened.

Always cover every state — crash on empty dialog

If phrase 0 has no valid <next> targets (all conditions are blocked), the game crashes. Map out every possible combination of info_portions and ensure at least one phrase is reachable from phrase 0 in each state.

Common situation: quest fails or is abandoned, but the failure info_portion is set by on_fail with a one-tick delay after the dialog closes. Do not rely on on_fail info_portions as conditions for phrases the player might access in the same or immediately following conversation. Use the info_portion that triggered the fail (e.g. quest_abandon) instead.

<!-- Covers the gap between quest_abandon being set and on_fail executing -->
<phrase id="60">
    <text>player_post_abandon</text>
    <has_info>my_quest_abandon</has_info>          <!-- set immediately by give_info -->
    <dont_has_info>my_failed_talked</dont_has_info>
    <next>61</next>
</phrase>

Part 5 — Quest with task_manager

Quests live in configs/misc/tm_<location>.ltx. Each section is one quest entry.

Single-stage quest

[my_quest]
icon       = ui_inGame2_Strannoe_yavlenie
prior      = 1
storyline  = false
title      = my_quest_title
descr      = my_quest_text
target     = my_target_story_id
condlist_0 = {=actor_has_item(item_section)} complete
reward_money = 500
on_complete  = %+my_quest_done%
  • target — the story_id of the object to mark on the map. Resolved via get_story_object_id(target). Set to nil for no marker.
  • condlist_0 — checked every update tick. Syntax: {conditions} result. Result can be complete or fail.
  • =actor_has_item(section) — checks if the actor has an item whose section name exactly matches. Does not match by parent class: actor_has_item(medkit) will not match [my_item]:medkit.
  • on_complete — condlist effects run when the quest completes, e.g. %+info_portion =give_task(next_task_id)%.
  • reward_money — added to actor wallet on completion.

Quest fail condition

Add additional condlist_N lines. They are checked in order; the first one that matches wins:

condlist_0 = {+my_quest_done} complete
condlist_1 = {+my_quest_abandon} fail
on_complete  = %+my_reward_taken%
on_fail      = %+my_quest_failed%

on_fail works the same as on_complete — condlist effects that fire when the quest fails.

Timing: on_fail info_portions are set on the next update tick after the fail condition is met — not instantly. Do not use them as dialog conditions for the same conversation turn that triggered the fail. Use the info_portion that triggered the fail instead (see Part 4, "crash on empty dialog").

Two-stage quest (fetch → deliver)

Split into two tm_ sections. Stage 1 triggers stage 2 via =give_task() in on_complete:

[my_fetch_quest]
icon       = ui_inGame2_Strannoe_yavlenie
prior      = 1
storyline  = false
title      = my_fetch_title
descr      = my_fetch_text
target     = my_item_story_id
condlist_0 = {=actor_has_item(my_item_section)} complete
on_complete  = %+my_item_found =give_task(my_deliver_quest)%

[my_deliver_quest]
icon       = ui_inGame2_Strannoe_yavlenie
prior      = 1
storyline  = false
title      = my_deliver_title
descr      = my_deliver_text
target     = my_npc_story_id
condlist_0 = {+my_quest_done} complete
condlist_1 = {+my_quest_abandon} fail
reward_money = 500
on_complete  = %+my_reward_taken%
on_fail      = %+my_quest_failed%

=give_task(task_id) is an xr_effects function — verified to work in on_complete.

{+info_portion} in a condlist means "actor has this info_portion."

Giving a quest from dialog

<phrase id="3">
    <text>player_accept_string_id</text>
    <action>my_script.give_quest</action>
    <give_info>my_quest_given</give_info>
    <next>4</next>
</phrase>
function give_quest(actor, npc)
    task_manager.get_task_manager():give_task("my_quest_id")
end

The <action> tag triggers lazy-loading of my_script.script the first time the phrase is selected.

Handing an item back via dialog

<phrase id="40">
    <text>player_deliver_string_id</text>
    <has_info>my_item_found</has_info>
    <dont_has_info>my_quest_done</dont_has_info>
    <dont_has_info>my_quest_abandon</dont_has_info>
    <precondition>my_script.player_has_item</precondition>
    <action>my_script.receive_item</action>
    <give_info>my_quest_done</give_info>
    <next>41</next>
</phrase>
<phrase id="41">
    <text>npc_receive_string_id</text>
    <is_final>1</is_final>
</phrase>
function player_has_item(actor, npc)
    return actor:object("my_item_section") ~= nil
end

function receive_item(actor, npc)
    local item = actor:object("my_item_section")
    if item then
        alife():release(alife():object(item:id()), true)
    end
end

The <precondition> on the phrase hides "Here's your item" if the player used or dropped it. Without it, <give_info> fires and the quest completes even with no item in inventory.

Map markers

For a map marker to appear, the target object needs a story_id in its spawn section:

[my_quest_item]:medkit
$spawn   = "items\medkit"
story_id = my_quest_item
inv_name       = my_quest_item_name
inv_name_short = my_quest_item_name

Then in tm_<location>.ltx: target = my_quest_item.

inv_name and inv_name_short are separate fields but always use the same string id in the base game — override both when creating a custom item.

actor_has_item checks section name, not parent class. If you use a custom section, the quest condition must reference that section — actor_has_item(medkit) will not match [my_quest_item]:medkit.


Part 6 — Available task icons

All icon values verified to exist in the game's atlas (from tm_zaton.ltx, tm_jupiter.ltx, tm_pripyat.ltx, task_manager.ltx):

ui_inGame2_Artefakti_na_zakaz    ui_inGame2_Bezopasnoe_mesto
ui_inGame2_Instrumenti           ui_inGame2_Ischeznovenie_stalkerov
ui_inGame2_Karti_mestnosti       ui_inGame2_kompas
ui_inGame2_Lager_naemnikov       ui_inGame2_Logovo_krovososov
ui_inGame2_Mesta_evakuatsii      ui_inGame2_Naezd
ui_inGame2_Nedostupniy_taynik    ui_inGame2_Ohota_na_Himeru
ui_inGame2_Osobiy_zakaz          ui_inGame2_Pripasi
ui_inGame2_Put_na_plato          ui_inGame2_Reputatsiya
ui_inGame2_Sdelka                ui_inGame2_Strannoe_yavlenie
ui_inGame2_Temniy_biznes         ui_inGame2_Tri_tovarisha
ui_inGame2_Zamanchiviy_biznes    ui_inGame2_Vibros
ui_inGame2_Anomalnaya_aktivnost  ui_inGame2_Anomalnoe_rastenie
ui_inGame2_Bespilotniy_razvedchik ui_inGame2_Dolgi
ui_inGame2_Gipoteza              ui_inGame2_Issledovanie_anomaliy
ui_inGame2_Issledovatelskaya_gruppa ui_inGame2_Istoriya_dolga
ui_inGame2_Kontrakt_s_uchenimi   ui_inGame2_Kopachi
ui_inGame2_Krazha                ui_inGame2_Nochnaya_ohota
ui_inGame2_Oazis                 ui_inGame2_Ohota_na_Burerov
ui_inGame2_Ohota_na_Krovososov   ui_inGame2_Ohrana_uchenih
ui_inGame2_Poslednie_razrabotki  ui_inGame2_Radiotechnika
ui_inGame2_Evakuatsiya           ui_inGame2_Laboratoriya_X8
ui_inGame2_Neizvestniy           ui_inGame2_Neizvestnoe_oruzhie
ui_inGame2_Odin_vistrel          ui_inGame2_Propavshaya_razvedgruppa
ui_inGame2_Propavshiy_chasovoy   ui_inGame2_Radiopomehi
ui_inGame2_Razgovor_s_glazu_na_glaz ui_inGame2_Skoplenie_monolita

Part 7 — Info portions

Info portions are the main state-tracking mechanism for dialogs and quests. Declare every one you use in configs/gameplay/info_<location>.xml:

<info_portion id="my_npc_spawned"></info_portion>
<info_portion id="my_quest_given"></info_portion>
<info_portion id="my_item_found"></info_portion>
<info_portion id="my_quest_done"></info_portion>
<info_portion id="my_reward_taken"></info_portion>

Info portions set during gameplay are saved in .scop save files and persist across loads.


Part 8 — String table entries

All visible text must be in a string table file. You do not need to copy an existing st_*.xml — the engine scans the entire configs/text/<lang>/ folder. Create a new file:

gamedata/configs/text/eng/st_my_mod.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<string_table>

    <string id="st_my_npc_name">
        <text>My NPC</text>
    </string>

    <string id="my_quest_title">
        <text>Quest title shown in PDA</text>
    </string>

</string_table>

For Cyrillic content, use encoding="windows-1251" in the XML header and write the file with Python (open(path, 'w', encoding='windows-1251')).


Common pitfalls

Symptom Cause Fix
NPC walks away on spawn No custom_data in spawn section Add custom_data = scripts\<loc>\<file>.ltx
NPC walks away despite custom_data [smart_terrains] section missing Add [smart_terrains] / none = true to logic file
Fatal crash on NPC interaction target = actor in [remark@*] Change to target = story | actor
Logic file silently ignored [logic@name] root in dedicated file Change to plain [logic]
Can't open section 'bandit_terrain' Wrong terrain_sect for bandit NPC Use stalker_terrain — all factions use the same value
Quest completes immediately on accept actor_has_item(medkit) matches existing inventory Use a custom item section with a unique name
Quest completes without item (player used it) <give_info> in XML fires unconditionally Add phrase-level <precondition> that checks actor:object("section") ~= nil
Map marker never appears Item/NPC has no story_id, or target in task is nil Add story_id to spawn section, set target in task
NPC spawns every load No info portion guard in spawn function Wrap with has_info / give_info_portion check
Game crashes on dialog open after quest abandoned All phrase 0 branches blocked — empty dialog Add a fallback phrase using the abandon info_portion, not the on_fail one
Fallback phrase not shown right after abandoning Phrase condition uses on_fail info_portion which hasn't been set yet (one-tick delay) Use the info_portion set by give_info in dialog (instant) instead
NPC speaks player lines / player speaks NPC lines in actor_dialog actor_dialog phrase structure is opposite of start_dialog Phrase 0 = player in actor_dialog, phrase 0 = NPC in start_dialog
Script function not found Script lazy-loads only on first access Put spawn logic in a script called from bind_stalker:net_spawn
Cyrillic shows as garbage Heredoc writes UTF-8 Use Python open(path, 'rb'/'wb') for copying, open(path, 'w', encoding='windows-1251') for new Cyrillic files
grep reports "binary file matches" File is windows-1251 Use grep -a

Crash log location

_appdata_/logs/openxray_<username>.log

The [error] Arguments line names the exact missing id or file. Always check this first after a fatal error.

Clone this wiki locally