Skip to content

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_restrictor volumes 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_zones and 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

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:

[walker@guard]
on_actor_in_zone = my_safe_zone | walker@warn

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():create using the [space_restrictor] profile from configs/system.ltx (or another restrictor-derived section via zone_section).
  • Rewrite the sphere radius from Lua with utils_stpk.get_space_restrictor_data / set_space_restrictor_data (shape shtype = 0, radius from 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/:

; configs/mod_system_my_restrictors.ltx
[em_safe_zone_restrictor]:space_restrictor

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_section or space_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:
for name, _ in pairs(db.actor_inside_zones or {}) do
    printf("inside_zone=%s", name)
end
  • 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

See also