Quick navigation

Go to

Map, markers & zones

Zones

The open77_zones service: enter and exit events with hysteresis, the definition reference, the polling model and quotas, and why every containment rule must still be re-derived on the server.

14 min read2,996 wordsMarkdown
On this page10 sections

Define world-space areas for shops, safe zones, checkpoints and other gameplay rules. Zone queries determine whether a position is inside a shape.

Open77.zones.contains uses the same Lua implementation on the client and server, including boundary and vertex handling. Use client queries for presentation and server queries to validate gameplay.

Source: open77_zones.

Presentation and validation are different jobs

The client zone service is presentation. It is never proof.

open77_zones runs on a machine the player owns. It can say anything. An enter event means "this client reports it is near a point" β€” it is a cue to draw a prompt, play a sound, or show a marker, and nothing more.

Anything that grants must re-derive containment on the server, with Open77.zones.containsPlayer or Open77.zones.playersIn, before it acts. Because both sides run the same geometry against the same definition, that re-derivation is one call and not a hand-rolled second opinion β€” which is exactly what made it worth centralising.

The shared module

Three functions, present on both runtimes, callable from any resource β€” client or server, bundled or third-party. No permission, no dependency, no export round-trip: they are part of the Open77 namespace the same way Open77.json is.

Function Answers
Open77.zones.contains(zone, point [, options]) true / false, or nil, reason
Open77.zones.normalize(definition) a prepared zone, or nil, reason
Open77.zones.bounds(zone) { x, y, z, radius, minZ, maxZ }, or nil, reason

They are pure. They read no world, resolve no entity, hold no state and call nothing outside math. That is what lets the same code run on a dedicated server with no game attached.

local turf = {
    shape = "poly",
    minZ = 12.0, maxZ = 20.0,
    points = {
        { x = -1460.0, y = 90.0 }, { x = -1440.0, y = 90.0 },
        { x = -1440.0, y = 110.0 }, { x = -1460.0, y = 110.0 },
    },
}

-- Client: is the local player standing in it?
if Open77.zones.contains(turf, Open77.character.state().position) then ... end

-- Server: the same table, the same function, the same answer.
if Open77.zones.containsPlayer(source, turf) then ... end

point is a vector3 or any { x =, y =, z = } table β€” a vector is a table with those fields, so both forms are the same thing to this code.

Prepare once, test many

contains accepts a raw definition or a prepared one. On a raw definition it has to validate and derive on every call: for a polygon that means rebuilding the vertex list and re-running the shoelace area before the test can even start, which measured about eight times the prepared cost. If a zone is tested more than once β€” and a polled zone is tested ten times a second β€” normalize it once and keep the result.

local shop = assert(Open77.zones.normalize({
    shape = "box",
    position = { x = -1460.2, y = 99.9, z = 14.8 },
    size = { x = 12.0, y = 6.0, z = 4.0 },
    rotation = 37.0,
}))
-- shop.shape, shop.bounds.radius, shop.bounds.minZ ... are all readable.

A prepared zone is a plain table. It survives json.encode, pairs and the export boundary unchanged, and passing it back to contains, bounds, playersIn or containsPlayer costs one field lookup to recognise.

Shapes

shape picks the geometry. Omitting it gives the shape that shipped, so every zone definition written before this page existed still means exactly what it always meant.

shape Geometry
(omitted) or "cylinder" (alias "circle") Planar radius around position, plus a maxHeight band either side of position.z.
"sphere" A true 3-D ball: #(point - position) <= radius.
"box" position (centre), size (full extents), rotation degrees about the vertical axis.
"poly" (alias "polygon") points, a planar ring of 3–512 vertices, concave allowed, with an optional minZ/maxZ range.
"combo" (aliases "combination", "multi") zones, an array of definitions. Inside if any is. Nests up to 4 deep.

Every shape is closed. A point exactly on an edge, a face or a vertex is inside. That is a decision, not an accident: a crossing-number polygon test is ambiguous on its own boundary β€” the answer would depend on which side of a comparison a vertex happened to fall, and therefore on vertex order β€” so the boundary is settled explicitly, first, before the crossing count runs. It is the reason an edge and a vertex give the same answer on both runtimes.

sphere is not what an existing radius zone was

Worth stating loudly, because it is the one place a careless edit changes behaviour. A definition with no shape is a cylinder with a height band, not a ball: at radius = 10, a point 10 m directly above the centre is outside (the default band is Β±6 m). Adding shape = "sphere" to that definition makes the same point inside.

So sphere refuses maxHeight with max_height_unsupported rather than ignoring it. The only way that field reaches a sphere is somebody migrating a cylinder, and in that case the vertical behaviour has just changed under them and they need to be told.

Fields

Field Shapes Meaning
position cylinder, sphere, box; optional for poly/combo { x, y, z } centre. For a polygon or combo it is only the anchor β€” see attached zones.
radius cylinder, sphere Metres, 0 < radius <= 2000.
maxHeight cylinder Vertical tolerance either side of position.z. Default 6.0 β€” a zone on a plaza must not fire for a player on the overpass above it. Refused on a sphere.
size box { x, y, z } full side lengths (so a 12 m counter is x = 12, not 6), each 0 < size <= 4000.
rotation box, poly Degrees about the vertical axis. On a box it turns the shape. On a polygon the vertices are already in world space, so it only declares the heading they were authored at, and matters solely for an attached zone.
points poly 3–512 { x, y } or { x, y, z } vertices; z is ignored. A ring whose area is under 1e-6 mΒ² is refused as degenerate_polygon.
minZ / maxZ poly Independent and both optional. Absent means unbounded on that side β€” see below.
zones combo 1–64 child definitions.
attach any { entity = , player = , offset = , followHeading = }. See attached zones.

An unbounded polygon reaches the sky

A polygon with no minZ and no maxZ is an infinite vertical prism, matching PolyZone. That is genuinely right for gang turf and genuinely wrong for a shop floor: without a height range, a player on the walkway thirty metres above your shop is inside it.

Night City is vertical. Give a polygon a height range unless you have thought about it and decided you want the whole column. The cylinder's maxHeight default exists for the same reason and is applied for you; minZ/maxZ are not, because there is no sane value to guess from a ring of { x, y } points.

Concave polygons are handled properly

The containment test is the Franklin crossing-number form, which is correct for concave and self-touching rings. A convex-hull or half-plane test is not: on a C-shaped turf boundary it reports the hollow of the C as inside the zone. That is the classic zone bug β€” it looks fine on the map, it is invisible in casual play, and a player who notices it can claim turf from outside it.

The parity fixture and both test suites walk a U-shaped polygon in and out through its notch specifically to hold that line.

Zones attached to an entity

A zone can follow something: a leash around an NPC, a no-shooting bubble around a vehicle, a bodyguard radius around a player.

{
    id = "escort",
    position = { x = 0, y = 0, z = 0 },   -- the anchor the shape is authored around
    radius = 8.0,
    attach = { player = 12, offset = { x = 0, y = 2.0, z = 0 }, followHeading = true },
}
Field Meaning
entity Any Open77 entity id. 0 is the local player's body.
player A player id, resolved to their entity. One of entity or player is required.
offset Metres from the entity, in the zone's own frame β€” so with followHeading it turns with the entity and "two metres in front" stays in front.
followHeading When true the zone's rotation tracks the entity's yaw. Only meaningful for a box, or for a polygon with an off-centre anchor.

Resolution is the only impure part of the feature, and it is deliberately not in the shared module. Open77.zones.contains on a definition carrying attach returns nil, origin_required: pure geometry may not go and look where an entity is. Instead the caller supplies the answer.

Open77.zones.contains(zone, point, {
    origin = { x = ex, y = ey, z = ez },   -- where the anchor is right now
    rotation = entityYaw,                  -- optional
})

The bundled client service does that resolution for you every poll tick. On the server, pass origin to playersIn or containsPlayer β€” which is also how one definition gets reused at twenty different places without being redefined twenty times.

An anchor that will not resolve contains nobody. If the entity is despawned, gone, or simply not streamed in, the client service treats the zone as empty and fires exit if the player was inside. Holding the last verdict instead would look safer and is not: a player standing inside a zone attached to an entity would have kept that entity streamed, so "cannot resolve" and "player is inside" do not co-occur β€” and holding it would strand a caller waiting on exit forever the day its NPC dies.

Internally this transforms the query point into the zone's declared frame rather than moving the zone, so it is one translation and at most one 2-D rotation whatever the shape is: a 300-vertex polygon costs the same to re-site as a sphere, and the prepared zone stays immutable and reusable.

Grace: one expansion rule for every shape

options.grace expands a zone outward by a number of metres, with rounded corners β€” the Minkowski expansion, not a scaled-up copy. It means the same thing on all five shapes: how far outside the surface still counts.

That single knob does two jobs.

On the server it is tolerance. Open77.players.position is a replicated snapshot, and an honest player's latest position may not have landed yet. A validation exists to stop a client claiming to be somewhere else entirely, not to arbitrate centimetres, so a few metres of grace is usually right.

On the client it is the hysteresis band: the exit test is the enter test with grace set to the zone's hysteresis. One rule, so a box and a polygon get the anti-chatter behaviour the sphere always had without anybody writing a second boundary.

Grace reaches into a concavity too, because it is a true expansion: with grace = 3 on the U-shaped turf above, a point 2 m inside the notch is inside the zone.

The client service: open77_zones

Polls the local character transform against every registered zone and fires an owner-local event on each enter/exit edge.

resource "hideseek"
version "1.0.0"
dependency "open77_zones >=0.2.0"

client_script "client/main.lua"

No permission is required: the service reads the local character transform through Open77.character.state(), which is ungated.

Create

Exports are asynchronous, like every cross-resource call. Await the promise:

local handle

CreateThread(function()
    local promise, callError = Open77.exports.call("open77_zones", "create", {
        id = "safehouse",
        shape = "poly",
        points = {
            { x = -1465.0, y = 95.0 }, { x = -1450.0, y = 95.0 },
            { x = -1450.0, y = 105.0 }, { x = -1465.0, y = 105.0 },
        },
        minZ = 12.0, maxZ = 20.0,
        enterEvent = "hideseek:safehouseEnter",
        exitEvent = "hideseek:safehouseExit",
    })
    assert(promise, callError)
    local result, awaitError = promise:await()
    assert(result and result.ok, awaitError or (result and result.error))
    handle = result.handle
end)

AddEventHandler("hideseek:safehouseEnter", function(context)
    Open77.log.info("entered " .. tostring(context.id))
end)

Swapping a radius zone for a polygon is a change to the definition and nothing else. The export, the events, the handle, the hysteresis and the ownership rules are identical for all five shapes.

Field Meaning
id Owner-local identifier, 1–64 characters, [%w_:%-%.]+. Required.
(geometry) Any definition the shared module accepts, including attach.
hysteresis Metres the zone expands by for the exit test. A supplied value is clamped to 0.05–10.0; omitted, it is max(0.25, bounds.radius * 0.08), which is not clamped. The gap between the two tests is where neither event fires.
enterEvent / exitEvent Local event names fired on each edge. Both optional β€” a zone can exist purely to be polled with contains.

create answers { ok = true, handle, id, shape }, or { ok = false, error = <token> } carrying the shared module's own refusal token, so a definition the client refuses is one the server refuses identically.

The event payload is { id, handle }.

Query and remove

local function call(name, ...)
    local promise, reason = Open77.exports.call("open77_zones", name, ...)
    assert(promise, reason)
    return promise:await()
end

local inside = call("contains", handle).inside
assert(call("remove", handle).ok)

There is no update: recreate the zone under the same id if its geometry changes. contains answers with the service's own last-polled verdict, not a fresh read β€” for an immediate answer about an arbitrary point, call Open77.zones.contains directly; it is available to every resource and needs no export round-trip.

Ownership comes from GetInvokingResource(), never from an argument: remove and contains refuse a handle belonging to another resource with not_owner. A caller's zones are dropped automatically when its resource generation stops or reloads, and a stopped owner is swept within one second.

Polling, and what it costs

The service polls every registered zone against the local character position once per tick, at 10 Hz (100 ms) whenever any zone is within 50 m and 2 Hz (500 ms) otherwise. There is no per-zone rate; a caller who needs tighter timing than 100 ms should not build on this service. The whole poll is skipped while the local character is not attached (menu, loading screen, dead).

Before the real test runs, each zone is rejected against its bounding volume β€” one squared planar comparison. That is what makes a polygon affordable, and it is why the cost tracks zones that overlap the player rather than zones registered.

Containment queries first check the zone's bounding volume. Polygon queries outside that volume avoid per-vertex work; queries inside it scale with the polygon's vertex count. Use simple shapes where possible and profile large zone sets in your gamemode.

The practical limit

Two ceilings, and the second is the real one:

  • 256 zones per client across every resource combined (zone_limit), unchanged.
  • 4096 polygon vertices live across every resource combined (vertex_budget) β€” 128 zones of 32 vertices, or 256 of 16.

The zone count is not what hurts. 256 distant zones cost about 0.3 ms per tick, which at 10 Hz is a rounding error. What hurts is edges walked on a tick where the player is standing inside them: 256 overlapping 32-gons measured 1.3 ms per tick and 256 overlapping 128-gons 5.1 ms β€” a visible hitch at 60 fps. The 4096-vertex budget puts the absolute worst case (every polygon overlapping the player at once) at about 1 ms per tick.

In practice you will never approach it, because zones in different districts cannot overlap the player simultaneously. The budget exists so that a resource which does try gets a clean vertex_budget refusal at creation rather than a frame-rate mystery.

Server-side validation

-- One player, before granting anything.
local inside, reason = Open77.zones.containsPlayer(source, shopFloor, { grace = 2.0 })
if inside == nil then return refuse(reason) end   -- not knowable
if not inside then return refuse("too_far") end   -- genuinely outside
sell(source)

-- Everybody inside, nearest to the anchor first.
for _, entry in ipairs(Open77.zones.playersIn(turf, { bucket = 0, grace = 2.0 })) do
    payTribute(entry.playerId)
end

Open77.zones.containsPlayer(playerId, definition [, options])

true, false, or nil, reason. Those three are genuinely different, and collapsing them is how a validation becomes a bug:

  • false β€” the player's position is known and it is outside. A player in another routing bucket is also false: a different bucket is a different world, so they are not standing in this zone by any reading.
  • nil, "position_unknown" β€” the player has never reported a position. That is not "outside".
  • nil, "position_stale" β€” you passed maxAgeMs and the last reading is older than that.
  • nil, "player_not_found" β€” no such player.

Refusing on nil is a policy decision the caller has to make, not one this call may make silently. Pass maxAgeMs when a stale reading must not grant β€” without it the last known position is used however old it is, which matches Open77.players.get and Open77.players.nearby.

Open77.zones.playersIn(definition [, options])

An array of entries, possibly empty, or nil, reason. It is built on Open77.players.nearby and inherits its conventions exactly rather than inventing a second set: entries are { playerId, bucket, distance, fresh, ageMs, name, position, heading, speed }, and ties break by player id so two calls with the same input answer in the same order.

distance is to the zone's anchor, not to its boundary.

Option Default Meaning
bucket every bucket A number narrows to one routing bucket; false is every bucket, explicitly. A zone is a place, not a player, so it has no bucket of its own to borrow β€” unlike players.nearby anchored on a player.
maxAgeMs none Skip players whose last reading is older than this.
limit none Applied after containment, never to the bounding-sphere scan β€” a limit on the scan would return the nearest N players in the bounding sphere, most of which may be outside the zone.
grace 0 Metres of outward tolerance. See grace.
origin / rotation the declared anchor Re-site the whole zone. Required for a definition carrying attach.

The candidate scan is bounded by the zone's bounding sphere measured from its anchor, so it stays correct at any rotation. A polygon with no height range has no finite bounding sphere and scans the whole roster.

Refusal tokens

Stable snake_case, identical on both runtimes, so a caller can branch on them.

Token Meaning
invalid_definition Not a table.
unknown_shape shape is not one of the five names or their aliases.
invalid_position Missing or non-finite { x, y, z }.
invalid_radius Not finite, <= 0, or > 2000.
invalid_max_height Not finite, <= 0, or > 2000.
max_height_unsupported maxHeight on a sphere.
invalid_size Box size missing, non-positive or > 4000 on an axis.
invalid_rotation Not a finite number.
invalid_points Fewer than 3 vertices, or a vertex that is not a point.
too_many_points More than 512 vertices.
degenerate_polygon Area under 1e-6 mΒ² β€” collinear points are not a polygon.
invalid_height_range minZ/maxZ non-finite, or minZ > maxZ.
invalid_zones Combo zones missing or empty.
too_many_zones More than 64 children in one combo.
zone_nesting_too_deep Combos nested more than 4 deep.
invalid_attach attach present but malformed, or naming neither entity nor player.
invalid_point The queried point is not a finite { x, y, z }.
invalid_options options is not a table.
invalid_grace Negative, non-finite, or > 2000.
invalid_origin options.origin is not a finite { x, y, z }.
origin_required The zone carries attach and no origin was supplied.
zone_limit Client service: 256 live zones already.
vertex_budget Client service: 4096 live polygon vertices already.
not_owner Client service: the handle belongs to another resource.
zone_not_found Client service: no such handle.

Upgrading from the radius-only service

Every definition that worked before works unchanged, with the same numbers. position + radius + optional maxHeight + optional hysteresis is the cylinder shape, and it is what you get when shape is absent.

Two things did change, both deliberately:

  1. The vertical boundary now gets hysteresis too. Previously the exit test expanded the radius but not the height band, so a player standing exactly at maxHeight could chatter between enter and exit while standing still β€” the very thing hysteresis exists to prevent. grace now expands the whole volume. No definition changes; a zone can only flap less than it did.
  2. The event payload's handle is now populated. It was documented and always arrived nil. Handlers that read it start getting the number the documentation always promised.

See also

  • Vectors and quaternions β€” the other shared prelude, and the precedent this one follows.
  • Server Lua API β€” Open77.players.nearby, whose conventions playersIn reuses.
  • docs/writing-a-gamemode.md β€” section 2.5 on why a missing position is not "outside".
  • Resource exports β€” how the client service's asynchronous exports work.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try