World & props
Networked world doors
Server-owned automatic and interactive doors: dynamic discovery, native animations, elevator landing safety, routing buckets, access rules and client/server Lua exports.
On this page5 sections
open77_doors synchronizes native doors through a server-owned registry. Automatic doors use routing-bucket proximity; elevator landing doors follow the cabin. Native animations, sound and collision are retained.
Source: open77_doors.
Installation
Context menu administration
With open77_admin and open77_contextmenu, ALT-click a streamed door to copy
its exact ID, inspect its state, open/close it manually, change its lock/seal,
or restore automatic proximity opening. The menu framework defines no actions;
the admin package supplies them. See context menu integrations.
Door ID/inspector access requires command.admin.dev.doors.inspect; controls
require command.admin.dev.doors.control (and command.admin for the UI).
The restricted command is /admin.dev.doors.control <hex-id> <open|close|lock|unlock|seal|unseal|automatic>.
Its server-only cooperative open77_doors.adminControl(playerId, id, action)
export accepts only open77_admin, rechecks the actual operator's ACL,
and uses the server's routing bucket and 12m distance limit. It does not let a
client choose a bucket or claim somebody else's resource-owned door.
Manual opening still honors locks/seals and auto-close; unlock/unseal first when
needed. Lift opening remains elevator-controlled and is not offered manually.
Runtime requirements
Requires client and server 2.31.13+op77.58 or later, including the native door projection bridge.
Use a client containing the native door projection bridge and a server supporting server exports. Add this dependency to your gamemode:
dependency 'open77_doors >=1.0.0'
Freeroam includes it. The service depends on open77_elevators. If your server
uses an explicit resources.load allowlist, include both system resources.
Other gamemodes can opt in without importing Freeroam.
No new network protocol version is required. Deploying only Lua is not enough for clients which do not have the new projection bridge.
Discovery and authority
The client periodically discovers real Door entities and their live
DoorControllerPS within 80 metres. It proposes an opaque entity ID, position,
door type and optional elevator topology. The server validates player proximity,
rate and capacity, selects the routing bucket, and owns all subsequent state.
- IDs are strings such as
0xFC85EAE29622BAC2, never numeric Lua values. - The same world door in two buckets has two independent states.
- Discovered doors start closed, unlocked and unsealed. A player's quest/save locks are not imported into the multiplayer world.
- Automatic doors open within 3.5 m of an authorized player, remain open within 5 m, and close 1.2 seconds after the last qualifying player leaves.
- Interactive doors submit a request; they cannot change everyone else's state directly. Closing through a player standing within 1.5 m is rejected.
- Landing doors open only when the linked elevator is powered, idle, at that
floor and not marked
doorsClosed. An unresolved link remains closed and discovery retries when the elevator registry becomes available. - Late streaming applies the current state quietly. Later changes animate; an unchanged state does not restart its animation or sound.
Discovery is not a world attestation: a dedicated server cannot independently prove an arbitrary game-record hash. Security-sensitive servers should disable discovery and register their approved doors from server-owned data. Static scenery without a live door controller is not moved or removed. An NPC's local proximity does not open a door by itself: a network NPC goes through the NPC passage rule instead.
An interactive door that the player may open starts opening on that player's
screen as soon as it is used, before the server accepts the request; a refusal
closes it again. This is the door family of prediction.
Automatic, lift, locked and sealed doors never predict.
For player elevator trips use native buttons or the elevator resource's
requestCall / requestFloor exports: native arrival confirms completion to
the server. Administrative timed goTo operations keep their existing deadline
semantics; do not configure a deadline shorter than the native travel time.
Door synchronization does not repair a quest-specific cabin's authored geometry
or floor markers.
Calling the server API
Browsable function reference: server door exports and client door exports.
The service exposes asynchronous server exports, not a second mutable
client-side Open77.doors registry. The native client namespace remains useful
for inspecting local entities. Use exports for synchronized state:
local function doors(method, ...)
local pending, error = Open77.exports.call('open77_doors', method, ...)
if not pending then return nil, error end
return pending:await()
end
CreateThread(function()
local id = '0xFC85EAE29622BAC2' -- replace with a captured world door ID
local door, error = doors('get', id, 0)
if not door then print(error or 'Door has not been discovered'); return end
-- Claim the discovered door for this resource. Ownership uses the caller
-- identity supplied by the runtime, never an owner string in the arguments.
local owned, reason = doors('register', {
id = id, bucket = 0, position = door.position,
})
if not owned then print(reason); return end
local ok, reason = doors('configure', id, 0, {
automatic = true, autoClose = true,
openRadius = 3.5, closeRadius = 5.0, closeDelay = 1.2,
defaultAccess = false,
})
if not ok then print(reason); return end
-- Example: grant the authenticated server player ID 12 access.
doors('setAccess', id, 0, 12, true)
end)
Run awaited calls from a thread, event or command handler, not at file scope. Do not forward arbitrary client arguments to privileged server exports.
Server exports
| Export | Arguments | Result / purpose |
|---|---|---|
get |
id, bucket=0 |
Door snapshot or nil. |
list |
bucket=nil, offset=0, limit=16 |
{doors, total, nextOffset?}; limit 1β16. |
near |
position, bucket, radius |
{doors, total, truncated}; nearest 16, radius β€120 m. |
register |
descriptor |
New or claimed door snapshot; fails if another resource owns it. |
configure |
id, bucket, patch |
Atomic validated update; true or false, reason. |
setOpen |
id, bucket, boolean |
Set a non-lift door target state. Disable automatic mode for persistent manual control. |
setLocked |
id, bucket, boolean |
Lock/unlock; locking closes the door. |
setSealed |
id, bucket, boolean |
Seal/unseal; sealing closes the door. |
setAutomatic |
id, bucket, boolean |
Enable/disable server player-proximity opening. |
setAccess |
id, bucket, playerId, booleanOrNil |
Grant, deny, or remove a player override. |
setActions |
id, bucket, actions |
Declare which door actions (force, pay, hack) the door accepts; nil or {} withdraws them all. |
resolveAction |
ticket, accept, reason? |
Owner verdict on a pending action. true means the door opened (charge the player); false, reason means nothing opened, including your own refusal, which answers false, "refused_by_owner". |
setNpcPassage |
id, bucket, mode |
Which network NPCs may open the door: 'public' (default), 'resource', 'always' or 'never'; nil restores 'public'. See NPC passage. |
npcStats |
β | {accepted, refused = {reason = count}}: NPC openings admitted and refused since the service started. |
linkElevator |
id, bucket, elevatorId, floor |
Link a registered landing door to a valid same-bucket lift and zero-based floor. |
remove |
id, bucket |
Release a door owned by this resource. Discovery may subsequently adopt it again. |
setDiscoveryEnabled |
boolean |
Stop/start accepting new discovered doors; existing registered doors remain managed. |
changes |
cursor |
{cursor, reset, events}; up to eight events per page. |
register descriptors require id and position={x,y,z} for a new door;
bucket defaults to zero. Optional automatic, autoClose, lift,
elevatorId and elevatorFloor describe its initial topology. Claiming an
existing door preserves topology and state; configure it separately.
configure accepts only open, locked, sealed, automatic, autoClose,
defaultAccess, openRadius, closeRadius, closeDelay and npcPassage
(the same values as setNpcPassage). Radii are 1β12 m,
closing radius must not be smaller than opening radius, and delay is 0.2β60 s.
An invalid field rejects the entire patch. Locked/sealed doors cannot stay open.
Lift doors reject direct open/automatic control, including from their owner.
An explicit per-player denial overrides defaultAccess. A global lock or seal
blocks everyone, including granted players. This is a shared physical door:
once it opens for an authorized player, another player can follow through it.
Door permissions are not an anti-tailgating collision boundary.
Owners are resource name + runtime generation. A restart/stop releases its doors and rules. Player-specific grants are cleared on disconnect. The registry is in memory; initialize durable rules from your own resource/database at start.
Observing server changes
changes(cursor) offers a bounded data-only feed compatible with isolated
server Lua VMs. Events contain cursor, kind (changed, removed, request,
action, npc_request), door, reason and optional value. For request,
reason is the authenticated player ID and value is the requested open boolean;
for action, reason is the player ID and value is the action record described
below; for npc_request, reason is the NPC ID and value is the player whose
client simulates it. Read subsequent pages using the returned cursor. When
reset is true, rescan list and resume from the returned cursor: older events
expired or the service restarted. Door state changes are not published on the
host-wide event bus; door actions are (open77:doors:action, below).
List pagination is not a transactional snapshot. Rescan if the registry changes while traversing pages. Pages are bounded to fit the runtime's export budget.
Door actions: force, pay and hack
A locked door, or one closed to a player by defaultAccess = false, refuses an
ordinary open request. The game offers three other ways in: force (the Body
and Tech skill checks), pay (the paid door prompt) and hack (the door
quickhack and the breach minigame). On a network door each of them is a request
to the server carrying its kind. Nothing is allowed by default: a door
accepts only the actions its owning resource declared with setActions, and
without a declaration the client does not offer them on a locked door. Declare
them from the resource that registered the door, and depend on
open77_doors >=1.1.0. Players need a client whose native door bridge carries
action intents; an older client offers none of these on a locked network door,
so declaring actions never breaks it.
CreateThread(function()
local id = '0xFC85EAE29622BAC2' -- a door this resource registered
local ok, reason = doors('setActions', id, 0, {
force = true, -- accepted by the door service itself
hack = { approval = 'owner', range = 25 },
pay = { price = 500 }, -- always approved by the owner
})
if not ok then print(reason) end
end)
| Field | Values | Meaning |
|---|---|---|
force, hack |
true or a table |
Offer the action. true means { approval = 'auto' }. |
pay |
table with price |
Offer the paid prompt. price is an integer 1β1,000,000,000 in your own currency. |
approval |
'auto' (force/hack default) or 'owner' (required for pay) |
auto: the service applies it after its own checks. owner: your resource decides each request. |
keepUnlocked |
boolean, default false |
false: the lock returns when the door next shuts. true: the lock stays open, as in single player. |
range |
1β60 m (hack only, default 30) | Remote reach of a door hack. Force and pay use the 6 m manual range. |
An invalid field rejects the whole declaration. Lift doors take no actions.
Checks before an action does anything. The player is in the door's bucket
and within range, the door is neither sealed nor a lift door, the action is
declared, and the player is not explicitly denied with setAccess(..., false):
an action bypasses the lock and defaultAccess, never an explicit per-player
denial. Requests share the ordinary limit of six per second. An action on a door
the player could open anyway is an ordinary opening: it is not charged and never
reaches the owner.
What an accepted action does. It unlocks and opens the door (an automatic
door then follows proximity as usual), and the player holds it open like a
granted player for ten seconds. Unless keepUnlocked is set, the lock returns
when the door shuts. Another player can still walk through while it is open.
Owner approval. An owner action creates a ticket and publishes one
host-wide event; the requesting client gets its answer only when you resolve it:
local balances = {} -- your own ledger, by player ID
local function isPolice(playerId) return false end -- replace with your job check
AddEventHandler('open77:doors:action', function(a)
if a.owner ~= GetCurrentResourceName() or a.stage ~= 'requested' then return end
if a.kind == 'pay' then
local balance = balances[a.player] or 0
if balance < a.price then
doors('resolveAction', a.ticket, false, 'insufficient_funds')
return
end
balances[a.player] = balance - a.price -- reserve first
if not doors('resolveAction', a.ticket, true) then
balances[a.player] = balances[a.player] + a.price -- nothing opened: refund
end
elseif a.kind == 'force' then
doors('resolveAction', a.ticket, isPolice(a.player), 'not_police')
end
end)
resolveAction(ticket, true) re-checks range, seal, declaration and price before
applying, and answers true only if the door actually opened; false, reason
means nothing opened, so never keep a charge on false. A refusal you pass
(accept = false) answers false, "refused_by_owner", never true, so an owner
that charges on true cannot charge a refused player. A ticket answers once,
only to the door's owner, and expires after five seconds (action_timeout). One
pending action per player, 256 in total. A refusal reason you pass is sent to the
client when it matches [A-Za-z0-9_.:-]{1,48}, otherwise action_refused.
The event payload is one table: stage (requested, accepted, refused),
kind, player, id, bucket, owner, and ticket/price for owner actions,
reason for accepted (auto/owner) and refused. auto actions publish only
accepted. Any resource can publish an event with this name; that is harmless,
because only a live ticket resolves. The same records appear in changes as
action entries.
What the client does not decide. The Body/Tech check result, the wallet and
the RAM pool are the player's own client state and are not trusted: a passed
skill check is only a force request. On a network door no local cost is
spent (no eddies for pay, no RAM for the door quickhack), so a refusal costs the
player nothing. Charge your own ledger when you approve. The paid prompt still
displays the price authored on the door's payment record, which is not
necessarily your price: use the same value or tell the player. The pry-open
animation of a Body check is not played; the door's own opening animation
follows the server's acceptance.
NPC passage
A network NPC (Open77.npcs.create) walks through network doors the
way it does in single player: its own AI asks the door to open when its path
crosses it. Exactly one client simulates each network NPC, and only that client
forwards the request, as a request for that NPC. The service then decides with
its own data, never the requesting player's position or rights:
- the requesting client is the NPC's current simulator, with a live lease and
the same authority epoch (
Open77.npcs.presenceanswers this; the service declaresworld.npcsandnpcs.foreignto read it); - the NPC is alive, in the door's bucket and within 6 m of it (its canonical position, the one the server accepted from the simulator);
- the door is not a lift door, not sealed, and its NPC passage rule admits it.
npcPassage |
Who opens the door |
|---|---|
'public' (default) |
Any network NPC, on a door that is unlocked and open to everyone (defaultAccess true). |
'resource' |
As public, and the owning resource's own NPCs also pass its locked or private doors. |
'always' |
Any network NPC, locked or private doors included. |
'never' |
No NPC. |
CreateThread(function()
local hideout, backRoom = '0xFC85EAE29622BAC2', '0x1B2C3D4E5F607182' -- captured door IDs
local door = doors('get', hideout, 0)
if not door then return end
-- The gang's own NPCs walk in and out of the locked hideout; nobody else does.
doors('register', { id = hideout, bucket = 0, position = door.position })
doors('configure', hideout, 0, { locked = true, npcPassage = 'resource' })
-- A shop's back room (already registered by this resource) stays shut to every NPC.
local ok, reason = doors('setNpcPassage', backRoom, 0, 'never')
if not ok then print(reason) end
end)
An admitted NPC opens the door for everyone, like a player. When a resource or
always rule lets an NPC through a locked door, the lock is lifted only until
the door shuts. The NPC holds the doorway open while it stands within the door's
closeRadius, for at most ten seconds per request, and a player's close request
is refused while it is in the threshold (doorway_occupied). Once it has walked
through, the door's own timer closes it (closeDelay after the last NPC or
player left). A dead NPC, or one that left, holds nothing.
A refused NPC request changes nothing (no reply, no revision), so the door stays
shut without a flicker. The refusal reason is counted in npcStats (locked,
npc_passage_denied, ...):
RegisterCommand('doornpcstats', function()
local stats, reason = doors('npcStats')
if not stats then print(reason); return end
print('NPC openings admitted: ' .. stats.accepted)
for why, count in pairs(stats.refused) do print(why, count) end
end, true)
A locked door also removes the NPC path through it in the engine, so an NPC's AI
never walks up to it on its own. A moveTo whose destination is behind one is
therefore probed first: the NPC walks to the closest reachable point, in front
of the door, and asks the door to open for it, admitted or refused by the rule
above. A resource or always rule opens it and the walk goes on through;
otherwise the NPC stays there, re-checking once a second, and after about five
seconds the task fails with reason path_blocked. It never passes through a
closed door (see NPC tasks). Requests are
limited to two per NPC per second (counted only once the sender proved it
simulates that NPC, so nobody can use up another client's budget) and twelve per
simulating client per second; at most eight NPCs hold one door at a time.
Admitted openings appear in changes as npc_request.
NPC passage needs open77_doors >=1.2.0 and players on a client with the
NPC door bridge. The server advertises the rule in every snapshot
(npcPassage); an older server advertises none, and a newer client then sends no
NPC request at all. An older client never sends one: its NPCs keep stopping at
managed doors. Vanilla crowd NPCs are not network NPCs and never open a managed
door.
Client exports and events
Call Open77.exports.call('open77_doors', name, ...) and await as above:
| Export | Arguments | Result |
|---|---|---|
get |
id |
Interested server snapshot or nil; not proof the native object is streamed. |
list |
offset=0, limit=16 |
{doors, total, nextOffset?} within this client's interest. |
requestOpen |
id, boolean |
Queue player intent; server acceptance arrives separately. |
Client events:
AddEventHandler('open77:doors:streamedIn', function(id, state) end)
AddEventHandler('open77:doors:updated', function(id, state) end)
AddEventHandler('open77:doors:streamedOut', function(id) end)
RegisterNetEvent('open77:doors:requestResult', function(id, accepted, reason) end)
Here "streamed" means server interest, not REDengine object residency. An
interested state can arrive before the native door; the service retries after
the object streams. Snapshots contain ID, bucket, position, revision, open,
locked, sealed, automatic, autoClose, lift, elevatorId and elevatorFloor, plus
canOpen (this player may open it with an ordinary request), npcPassage (the
door's NPC passage rule) and, when the owner declared door
actions this player may attempt, actions: { force = true, hack = true, pay = price } with only the declared keys.
Native Open77.doors.near(radius) and Open77.doors.state(id) now also expose
networkInstance, elevatorId and elevatorFloor alongside existing local
fields. Local state may briefly lag its authoritative target during transport.
Existing local door mutations return false, 'server_authority_required' for
managed doors. Internal projection methods (setNetworkEnabled,
applyNetworkState, forgetNetworkState, takeNetworkRequests,
setNetworkPredictionPolicy, setNetworkActionPolicy, requestNetworkOpen,
resolveNetworkRequest) are restricted to the open77_doors system resource
and are not gameplay APIs.
Limits and diagnostics
The service bounds the registry to 4096 doors, interest to 256 nearest doors per player, discovery to 20 proposals/second/player, and requests to 6/second/player. Inactive unowned doors expire after ten minutes out of interest. State sends retry on queue backpressure; bucket resets precede the new state.
Typical rejections: too_far, access_denied, doorway_occupied,
elevator_controlled, proximity_controlled, not_owner, invalid_lift,
invalid_hysteresis, topology_conflict, registry_full, rate_limited.
Door actions add action_not_allowed, invalid_action, sealed,
action_pending, action_busy, action_timeout, price_changed,
unknown_ticket, unknown_door, refused_by_owner and the owner's own refusal
reason. NPC requests, counted in npcStats rather than answered, add
not_npc_authority, unknown_npc, npc_not_alive, wrong_bucket, locked,
npc_passage_denied and invalid_npc_request; an invalid rule value is refused
with invalid_npc_passage.
For local validation, server/server.doors-local.jsonc enables the explicit
open77_doors_test laboratory. It is masterless and loopback-only. Never deploy
that profile as a public server configuration.
The profile uses your local acl.jsonc. Grant only the required test commands
to the test account (command.doors.test.rule, and optionally
command.elevator.goto / command.elevator.list). Do not publish identities,
private keys or a permissive test ACL with a server release.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
