Skip to content

Runtime NPCs

Borys Stelmakh edited this page Sep 17, 2026 · 5 revisions

Runtime NPCs

Spawn living creatures into the world and shape their identity, appearance, AI, quest role and dialog — all at runtime, with no FunkCode edits. The high-level way is the npcobj wrapper, an OOP layer over the sacred.npc_* bindings.

Verified on Sacred Gold Steam, build 2.0.2.28 (2006-10-13).

local N = require("npcobj")

Spawning

local cap = N.spawn(297, 2793, 2284)   -- creature type 297 at world tile (2793,2284)
local foe = N.spawn_here(286)          -- spawn at the hero's current tile
local sword = N.spawn_item(0x1234, 2790, 2280)  -- drop an item on the ground
  • World coordinates are tiles (kx, ky) — the same system the map viewer and o:teleport(kx,ky) use.
  • Creature type_ids come from require("npc") (474 named creature constants).
  • N.spawn_template(arch, opts) builds a ready-made archetype (guard, enemy, townsperson, quest NPC, companion…) with one surgical delta per option.
  • Persist a reference so it survives re-fires of your setup code: local cap = N.spawn(...):save("captain") then later N.get("captain"). A character that must also survive a savegame load is a persona.

A name is unique

A template's name ("res:KEY") is the creature's script name, and the engine keeps one creature per name. A CreateNPC record with a name that is already in the world creates nothing, so spawn_template returns nil; the engine sends the existing creature to the record's position instead. Live: four named_enemy brigands spawned under one name gave a single brigand, and the log showed that brigand teleported once for every extra record.

Give every named creature its own key, or leave name out for creatures nothing addresses by name (an ambush, a crowd). A creature without a name shows its type's own name on the nameplate.

Identity & appearance

cap:set_name("Captain Miles")     -- name shown on the nameplate
cap:set_level(35)
cap:set_faction(0)                -- faction id (0 = neutral/hero side here)
cap:equip(...)                    -- give weapons/armor (see npcobj source)
print(cap:name(), cap:type(), cap:pos())   -- read-back helpers

Behavior & AI

Method Effect
cap:make_friendly() non-hostile, hero-aligned
cap:make_aggressive() / :make_hostile() attacks
cap:make_combatant() / :make_soldier() / :make_guard() combat-capable archetypes
cap:make_immortal_passive(level) invulnerable + stationary + neutral — the quest-giver stance
cap:set_invulnerable(on) toggle invulnerability
cap:set_stationary(on) hold position
cap:set_hp(n) / :set_level(n) stats
cap:set_disposition(...) fine-grained friend/foe disposition
cap:wake() / :activate() kick the AI awake (runtime-spawned creatures start dormant)
cap:teleport(kx,ky) jump there. The NPC's home stays behind — see Walking. :walk_to is the same teleport, kept for old scripts
cap:go(kx,ky, on_arrive) / :place(kx,ky) walk there / jump there and make it home

Walking, and an NPC's home

rocheford:go(3240, 2460, function(o, placed)
  -- he is there; placed = true when he had to be put there
end)
rocheford:walking()      -- still on the way?
rocheford:arrive()       -- end the walk now: put him there, on_arrive fires

go uses the shipped NPC_Goto record and watches the walk. An NPC that has not left its tile for ~4 s is sent again, and after three tries it is put on the spot. Talking to it pauses the watch, and the walk goes on when the window closes. A walk is Lua state: a savegame keeps the NPC where it stood, so after a load walk it again or place it.

Every creature has a home. The engine's idle AI sends a creature that strays more than ~3.7 tiles from its home back there, every now and then. A runtime spawn's home is where it was created, and a teleport does not move it — only party members get theirs moved along with the hero. Measured live: NPCs sent 30 tiles walked 5–15 and stopped; one teleported 500 tiles from its home did not move at all. So:

Call Effect
o:go(kx, ky, ...) moves the home to the destination together with the walk
o:place(kx, ky) teleport and make that spot home — for an NPC that should hang around a place; it mills about within those few tiles and walks back if pushed off
o:home(kx, ky) set the home only

All three use the SetNPCState sub-op 4d (Vb.ST.anchor), in the same tile units as NPC_Goto.

o:state(...) runs any SetNPCState sub-ops (Vb.ST.*) on the NPC. While the NPC is bound to its dialog node and not muted (set_talkable(false)), the record also re-binds that node — the way vanilla writes a home change, next to 09 <node>. Live, a home change on its own once left a talkable NPC ignoring clicks.

Quest-givers & the "?!" icon

cap:make_quest_giver("Captain Miles", 35)  -- immortal + passive + bind + marker
cap:quest_icon(true)                       -- the overhead "?!" quest-giver glyph

make_quest_giver is the one-call path: it makes the NPC immortal & stationary, binds it as a dialog NPC (so the talk window opens), and gives it the marker. Under the hood it calls :make_immortal_passive() + :bind_quest().

Talk detection

The proven "is the player talking to this NPC right now?" signal is exposed as rising/falling-edge callbacks:

cap:on_talk(function(o)        -- fires once each time the window opens
  -- e.g. advance the quest, recruit, spawn the next NPC
end)

cap:on_talk_end(function(o)    -- fires once when the player presses OK / closes
  o:dialog_off()               -- clear the line + "?!" so it doesn't re-open
end)

if cap:in_dialog() then ... end   -- instantaneous check

These work for any spawned NPC — no hooks or quest tables required. They're backed by cCreature+0x200 bit 0x400 (validated live).

Dialog text

To make the NPC speak your line in the native talk window, bake the text and point say() at the vanilla node it plays:

require("text").named("CAPMILES_GREET", "Halt, stranger. ...")
cap:say("CAPMILES_GREET", "DQ_15024_OFFEN")

Full mechanism, node discovery and the catalogue: Dialog Text.

The SDK forgets an NPC's text override once the NPC dies, vanishes, or its handle comes back as another creature. (The engine's "dead" state looked like an open talk window to the text hook, and a slain slaver's line once came out of every vanilla soldier in town.)

An NPC's own dialog node

cap:bind_quest("Captain Miles", true)
cap:dialog{ text = "CAPMILES_GREET",
  buttons = {
    { label = "res:1037", on = function(o) ... end },   -- Accept
    { label = "res:1038" },                             -- Reject: just closes
  } }

Up to four buttons. Labels are res:<id> (1024 OK, 1037 Accept, 1038 Reject; sections.lua has them as S.OK, S.ACCEPT, S.REJECT) or a res:<KEY> baked with text.lua; on runs on the heartbeat after the click.

A button can also carry records that run inside the engine at the moment of the click, before on. That is how a price is paid: the check and the payment are one engine step, and Lua only reads the result afterwards. Live.

{ label = "res:1037",
  records = Vb.if_({ Vb.P.has_gold(5000) },
                   { Vb.add_gold(-5000), Vb.set_var("MYQ", 4) },
                   Vb.info("NEED_GOLD")),              -- else: the on-screen message
  on = function(o)
    if V.get("MYQ") == 4 then ... end                  -- paid
  end }

HasGold reads the hero's purse and takes 65,535 at most. To answer at once with a second window, redefine the node (o:dialog{...} again) and open it with Sc.say(name) (Cut Scenes).

o:set_talkable(false) makes the NPC ignore clicks and keeps its nameplate; bind_quest again makes it talk.

Companions

cap:make_companion(0, { combat = true })   -- follows the hero, joins his fights
cap:dismiss()                              -- stop following, restore neutral AI

make_companion sends the engine's own follow command (0x10B, what the script keyword follow does). The NPC follows the hero on a leash, is on the hero's side, keeps its level and gets a portrait in the party panel. With { combat = true } it gets the hireling AI and attacks what the hero attacks; without it, it follows and only defends itself. The first argument, a quest id, is accepted for older scripts and ignored.

The Quick Start companion keeps "he joined" in an engine variable and calls make_companion again from its persona's setup, which runs after every load.

Companions also show up in the game's own escort panel: the SDK registers them in the hero's party vector through the engine's own command, the same one the script keyword follow uses (sacred.npc_roster_add / roster_remove, and sacred.roster_dump() as the diagnostic).

Characters that survive a save (persona)

A savegame brings runtime NPCs back with the same handles, so a mod that spawns in V.on_ready adds a second copy on every load. persona.lua handles that: a character is spawned once, and after every load the same creature is adopted.

local P   = require "persona"
local NPC = require "npc"

local roch = P.define("roch", {
  type = NPC.ROCHEFORD, name = "res:ROCH_NAME",
  home = { 2713, 2196 },               -- where he first appears
  immortal = true,                     -- not allowed to die
  setup = function(o, adopted)         -- after every spawn and every adopt
    o:stance(1, 7)
    o:bind_quest("Rocheford", true)
  end,
})
P.watch()                              -- bring every persona into each world

roch:move_to(2626, 2072)               -- he walks there, and the spot is remembered
roch:companion(true)                   -- joins the party
local o = roch:npc()                   -- the npcobj wrapper, or nil
Spec field Meaning
type Creature type. Required.
name "res:KEY": the nameplate and the script name, unique.
template The npc_templates archetype. Default quest_npc.
sub_id Optional CreateNPC sub id.
home { x, y } where he first appears. Without it, and without a remembered spot, he appears at the hero.
immortal, hp Not allowed to die: health is kept at hp (default 100000), and a fallen persona is revived, or placed again where he fell. Immortality is set on the creature type, so use a type no ordinary enemy has.
companion Join the party again after being placed again.
when A function: the persona exists only in worlds where it returns true. A storyline for one class passes require("classes").only(C.VAMPIRESS).active (a mod for some classes).
setup(o, adopted) Runs after every spawn and every adopt. adopted is true when the creature came back from the savegame.
Call Effect
P.define(id, spec) Define or redefine. id has at most 10 characters and names the engine variables SDKNPC_<id>_H, _X and _Y.
P.watch() In every ready world, bring each persona in. On the heartbeat, remember where each stands (every ~5 s), keep immortal ones alive, and send a stuck walk again.
P.get(id) / P.ensure_all() Look one up / bring all in now.
p:npc() The Npc, or nil while he is not in this world.
p:move_to(x, y [, walk]) Walk there and remember the spot. walk = false places him.
p:companion(on [, quest_id]) Join the party with combat AI, or leave it.
p:immortal(on) Switch immortality.
p:icon(kind) / p:node(name) / p:say(...) The Npc methods of the same names.

The handle lives in the engine variable SDKNPC_<id>_H, which the savegame carries. After a load the persona wraps that handle if it still holds a creature of its type. Only if it does not is a new one spawned: at the remembered spot (_X, _Y), else at home, else at the hero.

Shop keepers and trainers

A merchant, a blacksmith or a master of combat arts is not a special creature type — it is a flag opcode in the creature's own CreateNPC record, and the talk path opens the shop window when it sees the matching bit. The SDK ships the three templates:

local N = require "npcobj"

N.spawn_template("merchant", { type = NPC.MERCHANT, name = "res:MY_TRADER",
                               pos = "CPOS:HERO" })
N.spawn_template("smith",    { type = NPC.SMITH,    name = "res:MY_SMITH" })
N.spawn_template("trainer",  { type = NPC.MASTER_OF_COMBAT_ARTS, name = "res:MY_MASTER" })

Talking to them opens the trade, forge and trainer windows — the real ones, with the engine's own stock and prices. Any creature type can carry the flag, so a farmer can run a shop if that is what your quest needs.

Fighters, gangs and horses

Three more templates, each the shape the shipped scripts use.

-- A named fighter that takes on what comes near. Vanilla's soldiers carry side 08
-- and awake 12; a second op 02 in their CreateNPC is the weapon they hold.
local blade = N.spawn_template("named_guard",
  { type = 2, name = "res:BLADELOK_NAME", weapon = 1724, pos = "CPOS:HERO" })
blade:stance(1, 7)            -- ally: fights monsters, never the hero
blade:place(3226, 2763)       -- home, so he stays around
blade:wake()

-- A gang that stands around until you say so.
local chief = N.spawn_template("dormant_group",
  { type = NPC.SLAVER, name = "res:CHIEF", group = 980001, hook = "od_chief",
    pos = "CPOS:HERO" })
chief:stance(1, 7)            -- friendly for now: talkable, the escort leaves them be
chief:set_stationary(true)
-- ...the members: the same template without name and hook

-- A horse, the way the world's own are placed.
N.spawn_template("mount", { type = 550, pos = "CPOS:HERO" }):place(3231, 2767)
  • A quest_npc has its side switched off: it stands and turns to watch the hero. Anyone who should fight wants named_guard. weapon is an item type: vanilla's soldiers hold 1729 (sword), 1712 (dagger), 1724 (bastard sword) most often.
  • Turning a gang hostile after a talk, live: spawn the group friendly, as above — an enemy under the cursor is an attack, not a talk — and give the chief's button records = Vb.group_state(980001, Vb.ST.hostile) (vanilla's ambush: hostile side, awake), then in on call m:set_disposition("hostile") and m:set_stationary(false) for every member so they come at once.
  • hook is the death hook: the engine runs that section the moment the creature dies, with the corpse as its context. Vb.create_obj(type, "CPOS:res:CHIEF", { place = true, give = true, take = ... }) in it drops a quest item where he fell (World Objects). Live.
  • A hero model (types 1–9) can be spawned as an NPC: vanilla does it with the Seraphim, and a Gladiator (type 2) stood and talked live. Whether his combat AI works as a named_guard is not confirmed yet.

The glyph over the head, and dialog nodes

The "!" and "?" over a quest giver belong to the dialog node the NPC is bound to, not to the creature. Once an NPC is bound (bind_quest), both of the vanilla records work on it:

giver:icon("offer")            -- the "!"  (also "handin" = "?", "none")
giver:node("MY_SECOND_NODE")   -- move him to another node: window AND glyph
giver:node(nil)                -- unbind: no dialog, no glyph

That is how a shipped quest walks a giver from "I have work for you" to "come back when it is done" to "hand it in".

Lifecycle

cap:despawn()                  -- engine removal (the NPC is gone, its text override too)
cap:alive()                    -- false after death/despawn

For the raw sacred.npc_* / sacred.spawn_* bindings behind these methods, see the Lua API Reference.

Clone this wiki locally