Space Restrictors¶
space_restrictor objects are named 3D volumes in level data. They are used as trigger zones for actor/NPC logic, safe areas, and AI movement constraints.
Names you see in debug overlays (for example esc_2_12_stalker_wolf_kill_zone) are these level object names.
Why creating new ones is hard¶
Using existing restrictors is easy from Lua/config. Creating brand-new named restrictor geometry is not:
- New named
space_restrictorvolumes are authored in level data (SDK/editor pipeline), not normally generated by plain Lua. - In scripts, the reliable path is to consume existing named restrictors via
db.actor_inside_zonesand condlists. - Runtime zone spawning exists for some zone object types, but this is a different workflow than "add a new named map restrictor and reference it in
on_actor_in_zone".
Practical alternatives¶
When you want "trigger zone" behavior in a mod, these are the main options:
| Pattern | When to use | Drawbacks |
|---|---|---|
Preplaced named space_restrictor (db.actor_inside_zones, on_actor_in_zone) |
You can ship map edits or target existing map zones; want clean named triggers and condlist support | Requires level data authoring (or dependency on existing zones) |
Runtime-spawned zone object (alife():create(...) + shape data + callbacks) |
You need dynamic zone creation/removal at runtime from script | More advanced; not the same named-map-restrictor workflow |
| Script-defined geometric zone (distance/radius), polled by timer | Script-only mods, no map edits, low overhead | Approximate shape (usually sphere), no native zone name integration |
Script-defined geometric zone via actor_on_update |
Same as above, but simplest code path | Per-frame polling cost |
Example 1: Preplaced named restrictor (recommended when available)¶
Use this when the zone already exists in level data and you want robust named trigger behavior.
local was_inside = false
local function actor_on_update()
if not db.actor then
return
end
local inside = db.actor_inside_zones["my_safe_zone"] ~= nil
if inside and not was_inside then
printf("[my_mod] entered my_safe_zone")
elseif (not inside) and was_inside then
printf("[my_mod] left my_safe_zone")
end
was_inside = inside
end
function on_game_start()
RegisterScriptCallback("actor_on_update", actor_on_update)
end
function on_game_end()
UnregisterScriptCallback("actor_on_update", actor_on_update)
end
The same zone name can also be used in config logic:
Example 2: Spawned space_restrictor volumes with zone_enter / zone_exit¶
This pattern covers dynamically spawned restrictor volumes: spawn on the server, rewrite the sphere from Lua, bind zone_enter / zone_exit on the client object when it comes online, and seed membership if the actor already overlaps the volume.
Goals:
- Spawn a server restrictor-shaped object with
alife():createusing the[space_restrictor]profile fromconfigs/system.ltx(or another restrictor-derived section viazone_section). - Rewrite the sphere radius from Lua with
utils_stpk.get_space_restrictor_data/set_space_restrictor_data(shapeshtype = 0,radiusfrom config). - Track server ids (numbers), map id → zone row, and use
level.object_by_id(id):set_callback(callback.zone_enter / zone_exit, ...)once the client object exists. - Poll callback attachment for a few ticks after create; objects are not always online immediately.
- Seed membership once if the actor is already inside the sphere when the restrictor appears (enter callbacks may not fire for the initial overlap).
This remains separate from level-authored named restrictors in db.actor_inside_zones["zone_name"], but you get the same event-driven enter/exit behaviour for script-spawned volumes.
Do not assign se_obj.name directly on dynamic restrictors
For this guide, assume Anomaly Lua does not expose safe server-object name mutators (name_replace / set_name_replace) for dynamically created restrictors. Writing se_obj.name = "..." can shadow the bound name() method and crash scripts that later call obj:name(). Use a persisted zone-key -> id map with validation instead of name-based singleton tagging.
Component A: zone config (LTX)¶
Typical per-zone fields: level, center x / y / z, radius, optional owner, optional zone_section (if omitted, default to space_restrictor in your spawn helper).
If you want a custom restrictor section without editing system.ltx directly, define it in a DLTX patch file under configs/:
Because mod_system_*.ltx patches are merged into the system_ini() domain, alife():create("em_safe_zone_restrictor", ...) can resolve this section at runtime.
; configs/my_safe_zones.ltx
[zones]
rookie_village = true
[rookie_village]
level = l01_escape
owner = stalker
x = -206.0193
y = -20.3856
z = -148.0225
radius = 80
; zone_section = space_restrictor ; optional
Component B: spawn one restrictor + sphere radius¶
level.vertex_id(pos) at the zone center; db.actor:game_vertex_id() for the graph id passed to alife():create.
local function spawn_safe_zone_restrictor(zone)
if not (db.actor and db.actor:alive()) then
return nil
end
local pos = vector():set(zone.x, zone.y, zone.z)
local lvid = level.vertex_id(pos)
local gvid = db.actor:game_vertex_id()
local section = zone.zone_section or "space_restrictor"
local se_obj = alife():create(section, pos, lvid, gvid)
if not se_obj then
return nil
end
local shape_data = utils_stpk.get_space_restrictor_data(se_obj)
if shape_data then
shape_data.shapes = shape_data.shapes or {}
shape_data.shapes[1] = shape_data.shapes[1] or {}
shape_data.shapes[1].shtype = 0
shape_data.shapes[1].offset = vector():set(0, 0, 0)
shape_data.shapes[1].radius = zone.radius
utils_stpk.set_space_restrictor_data(shape_data, se_obj)
end
return se_obj.id
end
Component C: persisted ids (validated), clear Lua maps, ensure per level¶
Use a Lua table keyed by zone section name (for example safe_zone_restrictor_ids[zone.name] = id) and persist it in save_state/load_state.
Important behavior:
- Treat saved ids as cached references, not a blind truth source.
- On each ensure pass, validate
alife_object(id)still exists and still uses the configured restrictor section (zone_sectionorspace_restrictor). - If invalid, drop that id and spawn a new restrictor for the same zone key.
- Re-apply config-owned fields (position / vertices / radius) after reuse so config edits take effect.
clear_safe_zone_restrictor_state should only clear Lua tracking maps (SAFE_ZONES_BY_RESTRICTOR_ID, membership/callback tables, zone.restrictor_id), not the persisted zone->id cache.
ensure_safe_zone_restrictors_for_level runs with a live actor: it rebuilds Lua maps from the validated zone->id cache, spawns missing restrictors, assigns zone.restrictor_id, and fills SAFE_ZONES_BY_RESTRICTOR_ID. It ends with seed_safe_zone_membership_if_already_inside_sphere() (no arguments — it reads SAFE_ZONES[level.name()]).
local SAFE_ZONES_BY_RESTRICTOR_ID = {} -- server id -> zone table
local ACTOR_INSIDE_SAFE_ZONE_IDS = {} -- server id -> true while actor inside
local SAFE_ZONE_RESTRICTOR_CALLBACKS_DONE = {} -- server id -> true once callbacks attached
local SAFE_ZONE_RESTRICTOR_SEED_DONE = {} -- server id -> true after initial sphere seed
local safe_zone_restrictor_ids = {} -- zone.name -> server id (persisted)
local function clear_safe_zone_restrictor_state()
SAFE_ZONES_BY_RESTRICTOR_ID = {}
ACTOR_INSIDE_SAFE_ZONE_IDS = {}
SAFE_ZONE_RESTRICTOR_CALLBACKS_DONE = {}
SAFE_ZONE_RESTRICTOR_SEED_DONE = {}
for _, zones in pairs(SAFE_ZONES) do
for _, z in ipairs(zones) do
z.restrictor_id = nil
end
end
end
local function seed_safe_zone_membership_if_already_inside_sphere()
if not (db.actor and db.actor:alive()) then return end
local actor_pos = db.actor:position()
local zones = SAFE_ZONES[level.name()] or {}
for _, z in ipairs(zones) do
if z.restrictor_id and not SAFE_ZONE_RESTRICTOR_SEED_DONE[z.restrictor_id] then
local zone_pos = vector():set(z.x, z.y, z.z)
if actor_pos:distance_to_sqr(zone_pos) <= (z.radius * z.radius) then
ACTOR_INSIDE_SAFE_ZONE_IDS[z.restrictor_id] = true
end
SAFE_ZONE_RESTRICTOR_SEED_DONE[z.restrictor_id] = true
end
end
end
local function ensure_safe_zone_restrictors_for_level()
if not (db.actor and db.actor:alive()) then return end
clear_safe_zone_restrictor_state()
local zones = SAFE_ZONES[level.name()] or {}
for _, zone in ipairs(zones) do
local restrictor_id = safe_zone_restrictor_ids[zone.name]
if restrictor_id and not alife_object(restrictor_id) then
restrictor_id = nil
safe_zone_restrictor_ids[zone.name] = nil
end
if not restrictor_id then
restrictor_id = spawn_safe_zone_restrictor(zone)
if restrictor_id then
safe_zone_restrictor_ids[zone.name] = restrictor_id
end
end
if restrictor_id then
zone.restrictor_id = restrictor_id
SAFE_ZONES_BY_RESTRICTOR_ID[restrictor_id] = zone
end
end
seed_safe_zone_membership_if_already_inside_sphere()
end
Component D: attach callbacks + refresh¶
Bind only when level.object_by_id(restrictor_id) returns a client object. Re-run until SAFE_ZONE_RESTRICTOR_CALLBACKS_DONE is set for every id in SAFE_ZONES_BY_RESTRICTOR_ID.
local function on_actor_entered_safe_zone_restrictor(zone_obj, entering_obj)
if not (db.actor and entering_obj and entering_obj:id() == db.actor:id()) then
return
end
ACTOR_INSIDE_SAFE_ZONE_IDS[zone_obj:id()] = true
end
local function on_actor_left_safe_zone_restrictor(zone_obj, leaving_obj)
if not (db.actor and leaving_obj and leaving_obj:id() == db.actor:id()) then
return
end
ACTOR_INSIDE_SAFE_ZONE_IDS[zone_obj:id()] = nil
end
local function attach_safe_zone_restrictor_callbacks()
for restrictor_id, _ in pairs(SAFE_ZONES_BY_RESTRICTOR_ID) do
if not SAFE_ZONE_RESTRICTOR_CALLBACKS_DONE[restrictor_id] then
local obj = level.object_by_id(restrictor_id)
if obj then
obj:set_callback(callback.zone_enter, on_actor_entered_safe_zone_restrictor)
obj:set_callback(callback.zone_exit, on_actor_left_safe_zone_restrictor)
SAFE_ZONE_RESTRICTOR_CALLBACKS_DONE[restrictor_id] = true
end
end
end
end
local function refresh_safe_zone_restrictor_callbacks()
attach_safe_zone_restrictor_callbacks()
seed_safe_zone_membership_if_already_inside_sphere()
end
Component E: poll until online¶
Call refresh_safe_zone_restrictor_callbacks from actor_on_first_update after ensure_safe_zone_restrictors_for_level, and on actor_on_update whenever callbacks are still pending or at least every 250 ms (game.time()), because restrictors can lag a few ticks after alife():create.
local function safe_zone_restrictor_callbacks_pending()
for restrictor_id, _ in pairs(SAFE_ZONES_BY_RESTRICTOR_ID) do
if not SAFE_ZONE_RESTRICTOR_CALLBACKS_DONE[restrictor_id] then
return true
end
end
return false
end
-- Inside actor_on_update (simplified):
-- if safe_zone_restrictor_callbacks_pending() or (now - last_poll) > 250 then
-- refresh_safe_zone_restrictor_callbacks()
-- end
Component F: friendly-zone gating on callback membership¶
Use ACTOR_INSIDE_SAFE_ZONE_IDS as the primary “inside volume” signal, resolve the zone row via SAFE_ZONES_BY_RESTRICTOR_ID, then apply faction rules (get_actor_true_community, game_relations, and your own policy for neutrals, for example an MCM toggle via mcm_option("allow_neutral_bases") or another config source).
local function safe_zone_allowed_for_player_faction(z)
if not z.owner then
return true
end
local player_faction = get_actor_true_community()
if not player_faction then
return false
end
if game_relations.is_factions_friends(player_faction, z.owner) then
return true
end
return mcm_option("allow_neutral_bases")
and game_relations.is_factions_neutrals(player_faction, z.owner)
end
local function get_friendly_safe_zone_actor_is_inside()
if not (db.actor and db.actor:alive()) then return nil end
for restrictor_id, _ in pairs(ACTOR_INSIDE_SAFE_ZONE_IDS) do
local z = SAFE_ZONES_BY_RESTRICTOR_ID[restrictor_id]
if z and safe_zone_allowed_for_player_faction(z) then
return z
end
end
return nil
end
Example 3: Script-only geometric zone (timer poll)¶
Use this when you cannot edit levels and want to avoid per-frame checks.
local center = vector():set(-200.0, 0.0, 120.0)
local radius = 25.0
local was_inside = false
local function actor_in_fake_zone()
if not db.actor then
return false
end
return db.actor:position():distance_to_sqr(center) <= (radius * radius)
end
local function poll_fake_zone()
local inside = actor_in_fake_zone()
if inside and not was_inside then
printf("[my_mod] entered fallback zone")
elseif (not inside) and was_inside then
printf("[my_mod] left fallback zone")
end
was_inside = inside
return true
end
function on_game_start()
CreateTimeEvent("my_mod", "poll_fake_zone", 250, poll_fake_zone)
end
function on_game_end()
RemoveTimeEvent("my_mod", "poll_fake_zone")
end
This is reliable for script-only mods, but it does not create a real named restrictor.
Example 4: Script-only geometric zone (actor_on_update)¶
Use this only when you want the simplest implementation and cost is acceptable.
local center = vector():set(-200.0, 0.0, 120.0)
local radius = 25.0
local was_inside = false
local function actor_in_fake_zone()
if not db.actor then
return false
end
return db.actor:position():distance_to_sqr(center) <= (radius * radius)
end
local function actor_on_update()
local inside = actor_in_fake_zone()
if inside and not was_inside then
printf("[my_mod] entered fallback zone")
elseif (not inside) and was_inside then
printf("[my_mod] left fallback zone")
end
was_inside = inside
end
function on_game_start()
RegisterScriptCallback("actor_on_update", actor_on_update)
end
function on_game_end()
UnregisterScriptCallback("actor_on_update", actor_on_update)
end
Debugging¶
- Enable Debug HUD + Actor Inside Zone Info with
-dbg. - Print current named memberships:
- Inspect a known zone object:
local obj = level.debug_object("esc_2_12_stalker_wolf_kill_zone")
if obj and obj:is_space_restrictor() then
printf("found restrictor: %s id=%s", obj:name(), obj:id())
end