Skip to content

Zones and Barriers

Borys Stelmakh edited this page Sep 12, 2026 · 1 revision

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.

Contents

A zone in four lines

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.

How the engine really behaves

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).

One-shot zones

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.

The raw pulse

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".

Barriers

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.

Rules and limits

  • 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_ready is the place.
  • A barrier record holds 16 cells; Z.barrier splits longer walls for you.

What it is underneath

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.

Clone this wiki locally