Skip to content

World Objects

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

World Objects

Chests, clickable objects, map icons and the names that let a record address something you placed. All of it is the engine's own object layer — the SDK only builds the records the shipped scripts build.

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

Contents

A chest

local W = require "world"

local c = W.chest{
  x = 2796, y = 2287,
  gold  = 100,                  -- paid once, when it is first opened
  spill = { 5135 },             -- a pile of coins on the floor beside it
  on_open = function(c) sacred.log("opened " .. c.name) end,
}

A chest is one CreateObj record carrying a name and a take section — the shape StartCode uses 1,805 times. Opening it runs that section, which also reaches your Lua as the on_open callback.

Field Meaning
x, y (or pos) Where. A named position works too.
type W.CHEST.plain (default), .small, .wide, .tall, .book. Types 5214..5226 may hold a trap or a mimic, so they are not in that list.
name The object's own name; other records address it by this. Generated if omitted.
gold Gold paid once on the first open, with the engine's "+N" popup. The paid flag is an engine variable, so a savegame remembers it; c:arm() lets it pay again.
spill Object types dropped on the floor around it when opened.
records Extra records for the take section (a banner, a journal line).
on_open Lua callback, on the heartbeat after it opens.
create = false Do not place an object, only (re)define its sections — what a later session needs for a chest already in the world.

Methods: c:find() (its handle), c:fill(opts) / c:refill(opts), c:arm(), c:lock() / c:unlock() / c:open() / c:close().

A chest can be opened once. That is the engine's behaviour, not ours: plan one payout per chest, and place a second chest if the quest owes another.

Loot

Two ways, and vanilla uses both:

  • On the floor. spill = { 5135, 5134 } drops piles the player picks up. The types are the four the engine's own chest fill uses — 5132, 5133, 5134, 5135 — and the engine prices them itself, scaling with the hero: 600 gold per pile at the level this was measured on, the same for 5134 and 5135, so the type only chooses the sprite. Any object type can be spilled: a weapon, a potion, a quest item.
  • An exact amount. gold = 100 pays exactly that, through the engine's AddGold record with its "+N" popup, wrapped in an IF on a variable so it pays once.

Clickable objects

W.hotspot{ x = 2793, y = 2288, type = 6029,
           records  = Vb.info("YOU_PICKED_THE_BERRIES"),
           on_click = function() ... end }

A hotspot is a MouseEvent record: the object of that type at that spot runs a section when the player clicks it. Both shipped forms work — by type (type =) and by name (res = "res:MY_BUSH", with mode = 1).

A container is not a hotspot. Clicking a chest or a bookshelf opens it, and the click never reaches a hotspot. Give containers a take section instead (W.chest{...} does), and put hotspots on pickable objects — the gold-berries type 6029 the shipped quest NQ04 uses is a good test object.

Map icons

W.map_icon(2796, 2287, 2)     -- 2 = a cave mouth, 1 = a portal glyph

Icon 2 is what the shipped scripts use almost everywhere (303 records against 30 for icon 1).

Names and handles

A record addresses an object by name, and the engine only binds a name it already knows. The SDK asks it to learn one:

Call What it does
sacred.name_register(name) Give a new name an id, through the engine's own registrar (89 dynamic slots). Without this a CreateObj name is silently dropped and nothing can address the object afterwards. world.lua does it for you.
sacred.object_by_name(name) The handle of a named object.
sacred.object_at(type, x, y [, r]) The nearest object of that type within r tiles — no name needed at all.
sacred.section_run(name, handle) Run a section with that object as the context, for records that act on "this object".

A quest item on the ground

The daily fetch quests' target, as the shipped scripts write it (DQ_15054, "The Axe of Galadius"): 01 'res:<name>' 02 <type> 04 <pos> 41 'od_<id>' 8e 61.

local S, A, Vb = require "sections", require "actions", require "verbs"

S.define("od_my_axe")                                    -- its Take: section
sacred.on_trigger("SECTION:od_my_axe", function() ... end)
A.run(Vb.create_obj(1722, { 2510, 2395, 0 },
  { res = "res:MY_AXE_NAME", take = "od_my_axe", place = true, give = true }))
  • place (op 0x8e) lays it in the world; without it give hands the item over straight away. give (op 0x61) makes it a quest item: picked up, it goes to the hero's quest-item list.
  • Picking it up runs the Take: section, which reaches Lua as SECTION:<take>. Live. sacred.hero_items() makes a good second signal: the entry shows up with where = "quest".
  • The object is in the savegame, the Take: section is not — define it again every session. sacred.object_at(type, x, y, r) tells whether the item still lies there.
  • To drop one where a creature died, use the position name "CPOS:res:<name>" in its death hook (Runtime NPCs).

Doors and locks

W.lock(name) / W.unlock(name) / W.open(name) / W.close(name) act on a named object through SetObjState. They are a trigger thing — doors and gates — because the record sets the lock bit on a cTrigger. A container ignores them; gate a chest by when you create or fill it instead.

What the engine will not do

  • Fill a chest the SDK created. The engine's own FillChest record leaves it empty even when the chest is the section's context and the type is a real container. Its Take: hook cannot fill it either: the engine runs that hook with the hero as the context, and FillChest fills its context. Use spill or gold.
  • Lock a container — see above.

Clone this wiki locally