Skip to content

Native Quests

Borys Stelmakh edited this page Sep 14, 2026 · 2 revisions

Native Quests

A quest the engine drives: it lives in Sacred's own quest registry, so the game writes its journal entry, picks the category, registers the compass column, plays the accept and solved fanfares and keeps the state in the savegame. Your Lua decides when it starts, advances and ends.

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

Contents

The short version

local NQ = require "nativequest"
local Vb = require "verbs"

local q = NQ.define{
  id   = 9501,
  name = "Brigands at the Mill",
  -- records that run the moment the engine creates the journal entry
  enter_records = { Vb.quest_log(9501, 0, "res:BRIG_TITLE"),
                    Vb.quest_log(9501, 1, "res:BRIG_STEP1") },
  on_enter = function() sacred.log("the player took the quest") end,
  on_exit  = function() sacred.log("solved") end,
}

q:start()      -- set up + enter, one section run: fanfare, journal, compass
...
q:solve()      -- the vanilla "quest solved" sound and journal look

The lifecycle

Method Record What the engine does
q:setup() SetUpQuest Arms the quest: runs QIS_OnSetUp once, then QIS_Trigger. Sections owned by the quest stay dead until this runs.
q:enter() TriggerQuest Runs QIS_Trigger; if it runs to the end, the quest is entered: journal entry, category, compass column, accept fanfare, then QIS_OnEnter.
q:start() both The two above in one section run — what a giver's Accept button does.
q:solve() ExitQuest Journal state "solved", the vanilla sound, then QIS_OnExit.
q:fail() LoseQuest Journal state "failed", then QIS_OnLose.
q:log(sub, key) QuestBook Title (sub 0) or the next body line.
q:compass(x, y) / q:compass_off() QuestKompassPos The quest's own arrow.
q:state() — The live registry entry: index, name, flags, setup, entered, done, and the five section indices.
q:reset() — Forget "entered" and "done" so a test quest can be played again.

Choosing an id

The id decides the look, because the engine reads it:

Range Meaning
1..99 A story quest: journal category "main", the big grey story arrow. Only one makes sense at a time — there is a single story compass column per hero class.
100+ A side quest: category "secondary", the side arrow.
2600..3599 Taken: the engine recycles these for its own pool quests.
9000..9499 Taken: the side-arrow reader rejects them.
9500+ Free. Use this for your own side quests.

Two things about a journal entry that shape a storyline (both live, and true for hand-made sacred.questbook_* entries as well):

  • A solved entry loses its arrow. The story arrow is drawn only while the entry's state is below 100. To point the player at the next quest giver the moment a quest is done, start the next story quest right then and let its arrow follow the giver.
  • An entry holds ten body lines under its title. Once they are used up, further lines are dropped; for a long chain, move the arrow without a line or start a new quest for the next chapter.

The QIS hooks

Each hook is an SDK section named the way the engine expects (QIS_OnSetUp<id>, QIS_Trigger<id>, QIS_OnEnter<id>, QIS_OnExit<id>, QIS_OnLose<id>), and NQ.define creates all five for you. Each takes two kinds of content:

  • setup_records / trigger_records / enter_records / exit_records / lose_records — vanilla records that run at exactly that moment, inside the engine. Journal lines belong here: enter_records write the title the instant the entry exists.
  • on_setup / on_enter / on_exit / on_lose — Lua callbacks, on the heartbeat after the hook ran. Good for anything that needs your own state.

QIS_Trigger is the entry gate: an empty one always passes, which is how all 600 shipped trigger sections are written.

Journal, compass, category

Once the quest is entered, the journal entry is the engine's own:

  • Vb.quest_log(id, 0, "res:KEY") writes the title, any other sub appends a body line (10 maximum);
  • Vb.compass_pos(x, y, id) points its arrow, Vb.compass_off(id) hides it;
  • the category and the arrow style follow the id (see above) — nothing to set.

Objectives

objectives.lua drives the engine's own counters, the ones that show "3 of 5" on screen, survive a save and run a section at zero:

local O = require "objectives"

O.declare("brigands", function() q:log(1, "res:BRIG_STEP2") end)
O.start_kills("brigands", NPC.BRIGAND, 5)       -- kills of that type
O.start_pickups("rings", 4870, 3)               -- pickups of that item
O.left("brigands")                              -- remaining, total
O.banner("MY_MESSAGE_KEY")                      -- the game's own on-screen line

Drops feed the pickup counter: Vb.set_drop(creature_type, item_type, chance) makes that creature drop that item, and a chance of 0 removes the rule — exactly how a shipped quest arms and clears it.

Never put a kill counter on a creature that rises again (skeletons): the engine counts the type, and a risen skeleton is a different creature.

Areas

area.lua watches the hero on the heartbeat:

local Ar = require "area"
Ar.on_enter{ x = 2810, y = 2297, radius = 3, once = true, fn = function(a) ... end }
Ar.on_enter{ pos = "pos_ziel1501", radius = 5, fn = ... }   -- a named position
local x, y = Ar.pos("pos_ziel1501")                          -- resolve one

There is also the engine's own rectangle trigger — Vb.area_trigger(name, ll, ur) plus a section named OMO-1<name> — when you want the engine to do the watching.

Saving and loading

Quest state is the engine's: the registry flags (set up / entered / done) and the journal entry are written into the savegame and matched back by quest id, so a reload finds your quest where it was. Your own state belongs in engine variables (sacred.var_set, V.set), which are saved the same way.

The pieces that live only in the DLL — the SDK sections, the registry entry itself — are rebuilt automatically on every world load, and NQ.define only has to run once per session.

How it works underneath

sacred.quest_register(id, name) appends an entry to the engine's quest registry vector at run time, the same way the SDK grows the section table: allocate with the engine's own allocator, copy, append, re-point. The five section indices are filled from our own sections and refreshed whenever the section table is rebuilt. Nothing on disk changes, and a savegame's entry for your id is absorbed back into the fresh entry when the game reloads.

Live evidence from the first run: registry 601 -> 602 entries, the hooks resolved to our sections, flags 0x5 (set up + entered), the journal holding [1 9501 99], then the solved sound on ExitQuest.

Clone this wiki locally