-
-
Notifications
You must be signed in to change notification settings - Fork 3
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")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 ando:teleport(kx,ky)use. - Creature
type_ids come fromrequire("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 laterN.get("captain"). A character that must also survive a savegame load is a persona.
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.
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| 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 |
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 firesgo 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.
cap:make_quest_giver("Captain Miles", 35) -- immortal + passive + bind + marker
cap:quest_icon(true) -- the overhead "?!" quest-giver glyphmake_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().
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 checkThese work for any spawned NPC — no hooks or quest tables required. They're backed
by cCreature+0x200 bit 0x400 (validated live).
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.)
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.
cap:make_companion(0, { combat = true }) -- follows the hero, joins his fights
cap:dismiss() -- stop following, restore neutral AImake_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).
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.
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.
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_npchas its side switched off: it stands and turns to watch the hero. Anyone who should fight wantsnamed_guard.weaponis 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 inoncallm:set_disposition("hostile")andm:set_stationary(false)for every member so they come at once. -
hookis 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_guardis not confirmed yet.
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 glyphThat 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".
cap:despawn() -- engine removal (the NPC is gone, its text override too)
cap:alive() -- false after death/despawnFor the raw sacred.npc_* / sacred.spawn_* bindings behind these methods, see
the Lua API Reference.
Getting started
Authoring
- Quests and Dialog Authoring
- Native Quests
- Runtime NPCs
- Hero Classes
- Dialog Text
- Dialog Nodes (catalogue)
The engine's own verbs
Reference