Quick navigation

Go to

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.

13 min read2,964 wordsMarkdown
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.presence answers this; the service declares world.npcs and npcs.foreign to 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

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try