-
-
Notifications
You must be signed in to change notification settings - Fork 3
Zones and Barriers
Two things a quest needs from the ground itself: notice that the hero walked
somewhere, and stop him from walking somewhere. Sacred has both, they are the
same ones the shipped quests use, and zones.lua wraps them.
Everything on this page was watched working in game on 2026-09-12 (Sacred Gold Steam, build 2.0.2.28). Where the engine behaves in a way that will surprise you, the measurement is written down next to it.
- A zone in four lines
- How the engine really behaves
- One-shot zones
- The raw pulse
- Barriers
- Rules and limits
- What it is underneath
local Z = require "zones"
local Vb = require "verbs"
Z.define("harbour_gate", {
rect = { 2799, 2278, 2811, 2290 }, -- x1, y1, x2, y2, in hero_pos space
records = { Vb.info("HARBOUR_WARNING") }, -- the ENGINE shows this, at the step
on_enter = function(z, who) sacred.log(who .. " walked in") end,
on_leave = function(z) sacred.log("and out again") end,
}):arm()records are ordinary vanilla records (Vanilla Verbs) and the
engine runs them at the moment of the step — a banner, a sound, a journal line, a
spawn, a quest entered. on_enter / on_leave are your Lua, one heartbeat
later. You need neither: a zone with only records is a pure engine trap, a zone
with only callbacks is a pure Lua one.
Arm zones on every world load — the rectangles are not in the savegame.
This is the part worth reading. The engine does not send an "entered" event. It runs the handler every heartbeat that somebody stands inside, once per creature. Measured: one walk through a 13×13 square produced 50 runs, three of them in the same millisecond — the hero and two party NPCs.
So a naive zone with a message record fills the screen with it, once per step.
By default zones.lua therefore gives you one event per entry, using
vanilla's own trick: the handler section ends with the trigger deleting itself
(OMO13trg_haupteingang in the shipped addon deletes both cave entrances the
moment one of them fires), and the module puts the rectangle back once the hero
has left. Live: three walks through gave three ENTERED lines, three LEFT
lines and three re-arms, with the engine's message shown exactly once per walk.
who is "hero" when the hero was inside the rectangle at that moment, and
"someone" otherwise (a companion, a monster). "Left" is judged from the hero's
position; a zone tripped by some other creature while the hero is elsewhere
re-arms after repeat_delay heartbeats (default 8, i.e. 2 s).
Z.define("ambush", { rect = { x1, y1, x2, y2 }, once = true,
on_enter = spring_the_ambush }):arm()once never re-arms: the trigger is gone after the first step in, exactly like a
vanilla quest entrance. Live-confirmed: fires once, then silent forever.
raw = true hands you the engine's behaviour untouched — on_enter on the first
pulse, on_inside on every following one, on_leave after grace (default 3)
quiet heartbeats, and your records run on every pulse. Use it when you want "is
anybody standing here", not "who walked in".
An invisible wall on map cells. This is the engine's trigger object
(CreateTrigger), the thing vanilla lays across a road (nogo_vor_arena) or in
front of a chest, bound to cells with TriggerPatch and opened or closed with
SetTriggerState.
local gate = Z.barrier("north_gate", Z.line(2792, 2272, 2800, 2272, 2))
gate:close() -- closed and locked: the road is shut
gate:open() -- unlocked and open: walk through
gate:lock(); gate:unlock()Z.line(x1, y1, x2, y2, thick) builds a straight line of cells thick rows
deep; you can also pass any list of {x, y} cells. A barrier starts closed.
Live: nine cells wide and two deep across the road stopped the hero dead, and
opening it from Lua let him through. It draws nothing — there is no model, no
shimmer, nothing. If the player is meant to understand what stopped them, put
something there: a map icon (Vb.map_icon), a fence object, an NPC who says no.
-
Names are at most 58 characters (the engine builds
OMO-1<name>into a 64-byte buffer) and must not collide with a shipped trigger name. - A sector is 64×64 cells. A rectangle that crosses a sector edge makes the engine's registrar split it recursively — it works, but staying inside one sector is cleaner. The captain's sector in the first region, for example, is x 2752..2815, y 2240..2303.
- Corners are normalised by the engine and the Z coordinate is clamped to 0..16.
-
Nothing here is in the savegame (the trigger names and the binding tree are
saved, the rectangles and the cell patches were not seen there). Build zones
and barriers on every world load —
V.on_readyis the place. - A barrier record holds 16 cells;
Z.barriersplits longer walls for you.
| Record | Tag | What the SDK does with it |
|---|---|---|
| SetBaseTrigger | 0x04 |
registers the rectangle in the sector's trigger list |
| DelBaseTrigger | 0x05 |
removes it — from inside the handler for one-shot behaviour |
| CreateTrigger | 0x30 |
the named trigger object of a barrier |
| TriggerPatch | 0x32 |
binds that object onto map cells |
| SetTriggerState | 0x31 |
open 0x26 / close 0x27 / lock 0x06 / unlock 0x07
|
The dispatch that makes zones possible: when a creature stands on a trigger cell
the engine looks the trigger name up in its binding tree (qm+0x752c), which is
filled at script-load time only, finds nothing for ours, and falls back to
sprintf("OMO-1%s", name) → run that section (FUN_0055d260 at 0x55ddf3, and
FUN_005941a0 at 0x59817e for party NPCs). An SDK section with that name is
therefore the handler, with no engine patch at all.
See also: Vanilla Verbs, World Objects, Native Quests.
Getting started
Authoring
- Quests and Dialog Authoring
- Native Quests
- Runtime NPCs
- Hero Classes
- Dialog Text
- Dialog Nodes (catalogue)
The engine's own verbs
Reference