API & developer tools
Complete server Lua API
Every dedicated-server global, Open77 namespace, permission, constant and result shape: scheduler, events, commands, players, combat, routing buckets, loot, vehicles, NPCs, elevators, database and logging.
On this page46 sections
- Runtime, scheduler, events, commands, and JSON
- What the sandbox provides
- The host-wide event bus
- Cancellable events
- Resource lifecycle events
- Feature APIs at a glance
- Namespaced equivalents of the globals
- Entity lifecycle events
- Network callbacks
- Reload-surviving resource state
- Cyberware and living motion
- Cross-resource exports
- Resource-local file IO
- Resource key/value store
- Convars
- Runtime and resource control
- Runtime control permissions
- Everything here is queued
- Join-time readiness gate
- What a connection can tell you
- Connection control
- Operator-tunable settings
- Notifications
- Perspective policy
- Synchronized RP animations
- Cyberware
- Forced motion
- Player clothing
- Wanted level and NCPD dispatch
- Read this before building a chase on it
- What the vanilla route does that this does not
- Player appearance
- Player weapons
- Reading what a player is carrying
- Record catalogues
- Held items
- Players and authoritative life
- The rich read and the freshness decision
- Freezing a player
- Player visibility
- Unified health and stamina API
- Disconnecting and banning a player
- Moving a player
- Why not respawn
- A player in a vehicle
- setHeading
- Combat policy
- Scoped PvP
- Routing buckets
- Scope events
- Time and weather
- Per-bucket overrides
- onEnvironmentChanged
- Ground loot
- Vehicles
- Lifecycle and state
- Engine, lights and siren
- Transform and motion
- Performance ceilings
- Explode, and locking for one player
- Lifetime
- Finding a vehicle
- Player seats
- Body, glass, lights, and tires
- Doors and windows
- Detached panels
- Autonomous driving
- NPCs and tasks
- NPC lifecycle and state
- Task queue
- Elevators
- Voice topology and policy
- Ambient population
- What is a fraction and what is a switch
- What the client does with it
- The client half is read-only
- Refusals
- Clearing an area
- It removes what you own, and counts the rest
- Population suppression is per bucket, not per radius
- World props
- World effects
- Database
- Waiting for the database
- Admin events
- What they carry, and what they deliberately do not
- None of them is cancellable
- Driving the restart from a resource
- What a shutdown notice can and cannot promise
- Reserved, as a whole namespace
- Outbound HTTP
- Serving HTTP
- Logging
- Latent (chunked) client events
- Permission summary
- Reference coverage
Reference for the dedicated server's Lua runtime. These methods are available in server_script files and the server half of shared_script files. Client APIs are listed separately in the API reference.
Prefer the Open77.* names below. FiveM-style globals remain available where listed for familiar
resource code and as the low-level implementation surface.
For passenger combat, see Passenger drive-by:
Open77.players.setDriveByEnabled(playerId, enabled) controls server policy;
getDriveByState, isDriveByEnabled and isInDriveBy read it.
Writes require players.driveby; reads require players.life.read.
Use the Unstable .117 client and server, protocol 1.42.
Runtime, scheduler, events, commands, and JSON
| Function | Signature | Result / purpose |
|---|---|---|
CreateThread |
(function) |
Schedule a coroutine in this resource. |
GetGameTimer |
() |
Process-monotonic milliseconds used by the server scheduler. |
Wait |
(milliseconds) |
Yield the current managed coroutine; accepted range is 0β86,400,000 ms. |
SetTimeout |
(milliseconds, function) |
Schedule a one-shot callback and return its timer ID. |
ClearTimeout |
(timerId) |
Cancel a scheduled timeout. |
AddEventHandler |
(event, handler) |
Register a local handler and return its ID. |
RemoveEventHandler |
(handlerId) |
Remove a local or network handler. |
TriggerEvent |
(event, ...) |
Publish host-wide: every running resource that handles event receives it. true, or false, reason. |
TriggerLocalEvent |
(event, ...) |
The pre-bus behaviour: this VM's handlers only, nothing marshalled, no reserved-name filter. |
TriggerCancellableEvent |
(event, ...) |
Publish host-wide as cancellable and return a verdict handle, or nil, reason. |
CancelEvent |
() |
Inside a cancellable handler, veto the event. true, or false, "not_in_cancellable_event". |
WasEventCanceled |
() |
Inside a cancellable handler, whether an earlier handler already vetoed it. |
RegisterNetEvent |
(event, handler?) |
Register an authenticated client event; requires network.events. |
TriggerClientEvent |
`(event, playerId | -1, ...)` |
RegisterCommand |
(name, handler, restricted?) |
Register a server/chat command. Restricted commands require ACL command.<name>. |
GetCurrentResourceName |
() |
Return this manifest's resource name. |
GetResourceState |
(resourceName) |
Return the current server resource state. |
print |
(...) |
Write a resource-prefixed server log entry. |
json.encode |
(value) |
Serialize a bounded Lua value to JSON. |
json.decode |
(text) |
Decode JSON into safe Lua values. |
Limits are 1,024 scheduled tasks and 2,048 handlers per resource. Network events accept at most 32
arguments in a 48 KiB JSON envelope. During a network handler, global source is set from the
authenticated connection, never from client payload data.
What the sandbox provides
A server script runs in Lua 5.4 with the standard math, string, table, utf8 and coroutine
libraries, the json table above, print, and the globals in this guide. It has no os, io,
debug, package, require, load, loadfile, dofile or collectgarbage: each of those is
nil, so os.time() raises attempt to index a nil value. Wall-clock time is Open77.time.unix() /
GetUnixTime() (fractional seconds) or Open77.time.utc() (ISO 8601); elapsed time is
Open77.time.monotonic() or GetGameTimer(); random numbers are math.random; persistence is
Open77.kvp and the database API, never a file. Split a script across files with more
server_script lines in the manifest, not require.
The host-wide event bus
TriggerEvent is the server counterpart of the client bus: the event reaches every running
resource that registered a handler for that name, including the publisher itself. This is what
lets a framework be a core plus modules β chat:message, esx:playerLoaded-style events and
chatMessage all work across resources.
Delivery is queued, never re-entrant. Publishing appends to the host's queue; the host drains
it at the next tick boundary and pushes the event into each VM in resource-name order, so one
resource's C function never re-enters another resource's Lua state and one resource's error
cannot abort the fan-out. Ordinary events are then scheduled in each target's own scheduler, so a
handler may Wait.
Arguments cross by value, through the same marshaller as server exports: at most 32 values,
4,096 nodes, depth 16, 48 KiB total. Functions, userdata, cyclic tables and non-finite numbers are
refused. No Lua object is shared between VMs, so mutating a table after publishing it changes
nothing for the receivers. Failure tokens: invalid_event_name, reserved_event,
resource_stopping, resource_preparing, event_argument_limit,
event_payload_not_serializable, event_queue_limit.
source is not propagated across the bus. A publisher that wants the receivers to know which
player caused the event passes the id as an argument, the way chat:message(source, name, text)
does. Handlers registered with RegisterNetEvent also receive host-wide events, exactly as on the
client, so a handler that acts on authority must validate source rather than assume it.
TriggerLocalEvent keeps the behaviour TriggerEvent had before the bus existed: this VM's
handlers only, no marshalling, no reserved-name filter. Use it for a resource's own internal
signalling. Open77.events.emitLocal is the namespaced alias.
Reserved names. The platform's own vocabulary cannot be published host-wide by a resource;
TriggerEvent and TriggerCancellableEvent answer false, "reserved_event" (nil, "reserved_event" for the cancellable form). Reserved are the exact names onResourceStart,
onResourceStop, onResourceStarting, onResourceListRefresh, onPlayerConnecting,
onPlayerConnected, onPlayerDisconnected,
onPlayerReady, onPlayerRejected, onPlayerBucketChange, onPlayerAnimationChanged,
onPlayerMotionChanged, onPlayerLifeStateChanged, onPlayerLifeTransitionFailed,
onEntityBucketChange, onRoutingBucketPolicyChange, onLootPickup, onTunableChanged,
onEnvironmentChanged, onPropCreated, onPropRemoved, onEffectCreated, onEffectRemoved,
onLootCreated, onLootRemoved, onEntityCreated, onEntityRemoved,
playerDropped, playerJoining, onPlayerEnteredScope, onPlayerLeftScope,
playerEnteredScope, playerLeftScope, open77:combatAnomaly, plus the prefixes __open77,
onVehicle, onNpc, onElevator, onCyberware, onAbility, open77:resource:,
open77:player, open77:clothing:, open77:weapons:, open77:vehicles:ai: and
open77:admin:. Everything a
resource legitimately publishes stays open, including open77:weather:*, open77:loot:*,
open77:appearance:* and chat:*. TriggerLocalEvent is deliberately unfiltered: inside its own
VM a resource can only fool itself.
No permission is required to publish or to receive, exactly as on the client and in FiveM. What protects the platform is the reserved list, not a capability.
Cancellable events
Only events published with TriggerCancellableEvent can be vetoed; an ordinary TriggerEvent
ignores CancelEvent(). Cancellable handlers run inline during the drain, in resource-name
order and then in registration order, so Wait is not available inside one. Every handler still
runs after a veto β FiveM semantics β and a later handler reads the verdict with
WasEventCanceled().
The publisher reads the verdict through the handle it got back. It settles one tick later, when every running resource has been offered the event:
local verdict = TriggerCancellableEvent("chatMessage", source, name, text)
if verdict ~= nil and verdict:await() then return end -- a handler vetoed it
| Handle method | Result |
|---|---|
verdict:await() |
true when a handler cancelled, false otherwise. Needs a managed coroutine; answers false, reason outside one. |
verdict:canceled() |
The verdict once settled, or nil, status while still pending. |
verdict:status() |
pending, settled or unknown (released or expired). |
A verdict that nobody reads is forgotten 30 seconds after it settles.
Resource lifecycle events
Eight events describe the resource lifecycle, and every running resource receives all of them. They differ in what they carry and in when they run, not in who hears them.
| Event | Signature | Emitted |
|---|---|---|
onResourceStarting |
(resourceName) |
Inline, from inside the host's start of that resource, before its VM exists: GetResourceState(resourceName) reads starting inside the handler, and its dependencies are already running. Not a veto β see below. Never delivered to the resource itself (it has no VM yet; on a reload the outgoing VM is excluded). |
onResourceStart |
(resourceName) |
Once per start, into every running resource. The resource that started gets it from its own VM before any other resource is told; every other resource gets it on the next tick. |
onResourceStop |
(resourceName, reason) |
Once per stop, into every running resource. The resource that stopped gets it from its own VM, with reason (manual, restart, reload, dependency_stopped, runtime_error, shutdown), while it can still run code. |
onResourceListRefresh |
() |
The manifest tree was re-read: the console's refresh, or a change under a watched tree. No arguments, the FiveM shape. Queued like onResourceStart, after the starts, stops and reloads the refresh caused have announced themselves. |
open77:resource:starting |
(resourceName) |
Beside onResourceStarting, inline as well. |
open77:resource:started |
(resourceName, revision) |
Beside onResourceStart, with a revision. |
open77:resource:stopped |
(resourceName, revision) |
Beside onResourceStop, with a revision. |
open77:resource:refreshed |
(revision) |
Beside onResourceListRefresh, with the lifecycle revision current at the refresh. A refresh is a re-read, not a transition, so the revision is not incremented by it. |
onResourceStarting cannot cancel a start. CancelEvent() returns false, "not_in_cancellable_event". Handlers execute inline, in registration order, under pcall, before the resource runs. Wait is not available: an attempted yield is logged as an error and delivery continues to the next handler.
-- A resource that hands a shared lock to a sibling before the sibling's own scripts run.
AddEventHandler("onResourceStarting", function(name)
if name ~= "shop_inventory" then return end
print(GetResourceState(name)) -- "starting": its VM does not exist yet
releaseInventoryLock() -- runs before shop_inventory's server/main.lua
end)
-- An admin panel that re-reads the package set only when the tree was re-read.
AddEventHandler("onResourceListRefresh", function()
rebuildResourceTable(Open77.resource.list())
end)
Guard on the name when you mean your own resource, which is the common case (never for
onResourceStarting, which a resource cannot receive about itself):
AddEventHandler("onResourceStart", function(name)
if name ~= GetCurrentResourceName() then return end
-- this resource is up
end)
A reload is a stop and a start for every other resource, and neither for the resource
being reloaded: its outgoing VM already ran onResourceStop(name, "reload") and its
incoming VM already ran onResourceStart(name). No resource is ever told about its own
transition twice.
Use local handlers for optional cross-resource lifecycle adapters; peer events in the
open77:resource: namespace are discarded, so a client cannot forge one. A queued event
describes the transition, so check GetResourceState(name) when current availability
matters (a stop/start can occur before the next tick). Revisions increase for every host
transition. Remember the newest revision per resource and ignore older notifications:
scheduler order is not a lifecycle guarantee.
Feature APIs at a glance
The cyberware API separates definition, identity, management and
read permissions. Use the Gorilla Arms walkthrough for a complete
resource example; installation completion is asynchronous.
It supports coexisting arms / gorilla_arms and legs / double_jump
definitions. Install chooses the definition's slot; remove defaults to arms and
accepts options.slot="legs". Both use one character revision/receipt ledger.
onCyberwareJump(player, encodedResult) reports second-jump admission as
{sequence,ok,error}, not native movement completion. See the linked reference
for limits, additive persistence migration, client projection APIs and the
original protocol 1.27 boundary. Current combined protocol and validation are
recorded in the docs/dash-checkpoint.md.
The Dash / Air Dash API adds Open77.dash.define, grant, revoke,
cancel, current and capabilities. It uses separate players.dash.define,
players.dash.manage and players.dash.read permissions, session capabilities
and the existing cyberware identity. It never purchases or replaces an implant.
current(player).ownedByCaller reports exact caller ownership across projection
restarts without exposing the resource owner name.
onDashChanged reports correlated activation phases; onDashRejected reports
admission failure.
The reflex overdrive API adds Open77.reflex.define,
grant, revoke, cancel, current and capabilities, under separate
players.reflex.define, players.reflex.manage and players.reflex.read
permissions. It is a bounded real-time speed/handling buff on its owner: it
slows no bullet, slows no other player, and changes no clock anywhere. A
definition picks one of two client-owned stat tiers and its economy; it can
never name a stat or a modifier. onReflexChanged reports correlated activation
phases; onReflexRejected reports admission failure.
The Ground Slam API provides Open77.abilities.define, grant,
revoke, cancel and current, controlled by players.abilities.define,
players.abilities.manage and players.abilities.read. Combined movement
admission includes Dash/Slam recovery and forced motion; grants compose with
the existing implant ledger.
The hacking API adds permission-controlled Open77.hacking and
Open77.statuses services for definitions, admission, Self-ICE, purge, statuses
and owned protection policies. Matching implants share the existing cyberware
ledger in operating_system, self_ice and purge slots. The optional lab is
disabled by default. onHackingTransition carries platform-owned upload/status
notifications; it cannot be synthesized through resource/network events.
Namespaced equivalents of the globals
The namespaced equivalents of the runtime globals listed at the top of this guide are:
| Function | Signature |
|---|---|
Open77.runtime.luaVersion |
() |
Open77.time.monotonic |
() β monotonic seconds |
Open77.time.unix / GetUnixTime |
() β wall-clock seconds since 1970 UTC, fractional |
Open77.time.utc / GetUtcTimestamp |
() β the same instant as an ISO 8601 string |
Open77.resource.name |
() |
Open77.resource.state |
(resourceName) |
Open77.events.on / off / emit |
Same as AddEventHandler, RemoveEventHandler, TriggerEvent |
Open77.events.emitLocal |
Same as TriggerLocalEvent |
Open77.events.emitCancellable |
Same as TriggerCancellableEvent |
Open77.events.cancel / wasCanceled |
Same as CancelEvent, WasEventCanceled |
Open77.net.on / emitClient |
Same as RegisterNetEvent, TriggerClientEvent |
Open77.net.register / unregister |
(name, handler(source, ...)) β answer a client's Open77.net.call; source is the authenticated player id |
Open77.net.callClient / callClientAwait |
(playerId, name | {name=, timeout=}, ...) β ask one client a question, returns an Open77.Promise |
Open77.callbacks.* |
Alias table for the four callback functions above |
Entity lifecycle events
Five registries create and remove server-owned entities, and each announces its own
creations and removals. A sixth pair, onEntityCreated / onEntityRemoved, summarises all
five for a resource that cares about the host rather than one domain β FiveM's
entityCreated and entityRemoved.
| Kind | Created | Removed | Capability |
|---|---|---|---|
vehicle |
onVehicleCreated(id, resource, record) |
onVehicleRemoved(id, reason) |
none |
npc |
onNpcCreated(id, resource, template) |
onNpcRemoved(id, reason, resource) |
none |
prop |
onPropCreated(id, resource, model) |
onPropRemoved(id, reason, resource) |
world.props |
effect |
onEffectCreated(id, resource, effect) |
onEffectRemoved(id, reason, resource) |
world.effects |
loot |
onLootCreated(id, resource, item) |
onLootRemoved(id, reason, resource) |
world.loot |
| any | onEntityCreated(kind, id, resource) |
onEntityRemoved(kind, id, reason) |
world.entities.observe, plus the kind's own |
reason is the registry's own token β removed, expired, resource_stopped, and for loot
also picked_up and moved_bucket β or the free text (β€ 64 characters) the caller passed to
Open77.props.remove(id, reason) and its siblings. onVehicleRemoved carries a numeric
engine reason and, alone in the table, no owner: it predates the three-argument convention and
widening it would change a payload resources already read.
The per-registry event is authoritative; the generic one is a projection of it. Both are
raised by the same statement inside the host, so they cannot disagree about an id, an owner or
a reason β there is no second observer of the registry that could be stopped, filtered or
reordered on its own. When a resource handles both, the specific event runs first and the
mirror second; EntityLifecycleEventsLuaTests pins that order, so a scheduler change fails a
test rather than silently reordering a spawn.
-- open77.lua
permissions { "world.props", "world.entities.observe" }
-- server/main.lua: one ledger, whatever spawned it
AddEventHandler("onEntityCreated", function(kind, id, resource)
print(("%s %s appeared, owned by %s"):format(kind, id, resource))
end)
AddEventHandler("onEntityRemoved", function(kind, id, reason)
if reason == "expired" then print(("%s %s timed out"):format(kind, id)) end
end)
The mirror carries no authority of its own. world.entities.observe is an opt-in, not a
key: an event about a prop still costs world.props, because reading one does
(Open77.props.list() answers permission_denied:world.props without it) and a lifecycle
event that announced the prop anyway would route around that capability. So the generic feed
is exactly the union of what a resource could already see, under one name β never more. A
resource holding only world.entities.observe sees vehicles and NPCs, whose per-registry
events were never gated, and nothing else.
The opt-in exists because the feed is every registry at once. A resource that wants NPC lifecycle should not be handed every ground drop on the server as a side effect, and a host-wide firehose nobody asked for is a cost paid by every VM on every spawn.
There is no onPropUpdated, onEffectUpdated or onLootUpdated, and no generic
onEntityUpdated. An attached prop or effect republishes as it follows its target, so an
update event would be a per-tick firehose for a value Open77.props.get(id) answers on
demand. Creation and removal are the two transitions a resource cannot poll for without
missing one. onVehicleUpdated and onNpcUpdated remain, revision-carrying, for the two
registries that shipped with them.
FiveM's entityCreating is deliberately absent. It is a cancellable pre-event, and
answering it would mean running another resource's Lua handler from inside the create call β
re-entering a second lua_State from a first one's C function, which is the single invariant
the whole event bus exists to prevent. A resource that must veto a creation owns the create
call instead: the entity is its own.
All eight new names are reserved. A forged onEntityRemoved is the cheaper attack of the
pair: it makes a cleanup or inventory resource forget an object that is still in the world.
Network callbacks
A callback is a net event with an answer: the client asks with
Open77.net.call, this VM answers from Open77.net.register, and the reverse
direction runs through Open77.net.callClient. It needs the same
network.events permission as a net event and grants nothing more β a handler
must re-validate distance, ownership and permission exactly as a
RegisterNetEvent handler does. See Network callbacks for both
directions, the per-resource namespacing, timeouts, limits and failure reasons.
Reload-surviving resource state
Open77.state holds one value per resource, so that a reload can pick up where the previous VM
left off. It is not a key-value store, and it is not persistence: think of it as the note a resource
leaves for its own successor.
| Function | Signature | Result / purpose |
|---|---|---|
Open77.state.save |
(value) |
Store; nil clears. true, or false, reason. |
Open77.state.load |
() |
The last stored value, or nil on a fresh start. |
Open77.state.clear |
() |
Drop the stored value; the same as save(nil). |
No permission is required. Three properties decide how it is used:
- Call
savewhen the state changes, not on stop. A reload prepares the successor VM before stopping this one, so a write from a stopping resource is refused and returnsfalse. - It survives a reload, and deliberately not a stop.
stop,restartandrefreshdrop it, so an operator keeps a way to say "come up as you would at boot", and the server re-asserts itsstartup.commandsfor a resource that starts carrying nothing. - The value is round-tripped through JSON by the host. Store plain data: functions, coroutines
and metatables do not survive, and cycles are rejected with
unserialisable_state.
local round = Open77.state.load() or { number = 0, scores = {} }
round.number = round.number + 1
Open77.state.save(round)
Cyberware and living motion
Requires paired protocol 1.26 clients/server and the shipped native Gorilla
adapter; this release supports slot="arms", profile="gorilla_arms". It does
not expose an implemented legs/jump power. Server mutations are synchronous
admission calls returning a result table or nil, reason, not Promises. Native
projection, persistence and restoration complete later. Read methods return a
table or nil when unavailable; distinguish nil readiness from an empty arms.
| Function | Signature | Permission | Purpose |
|---|---|---|---|
Open77.cyberware.current |
(playerId) |
players.cyberware.read |
Read the ready durable implant record. |
Open77.cyberware.effective |
(playerId) |
players.cyberware.read |
Read the ready implant including an active temporary loadout. |
Open77.cyberware.activity |
(playerId) |
players.cyberware.read |
Read fresh authoritative Gorilla charge activity. |
Open77.cyberware.leaseState |
(playerId) |
players.cyberware.read |
Read a temporary loadout lease and restoration phase. |
Open77.cyberware.newOperationId |
() |
players.cyberware.manage |
Create an operation ID to retain across durable retries. |
Open77.cyberware.define |
(definition) |
players.cyberware.define |
Register resource-owned Gorilla Arms grades. |
Open77.cyberware.bind |
(playerId, character) |
players.cyberware.identity |
Bind the authenticated player to a server-selected durable character. |
Open77.cyberware.unbind |
(playerId) |
players.cyberware.identity |
Release this resource's character binding and runtime grants. |
Open77.cyberware.install |
(playerId, definition, grade, options) |
players.cyberware.manage |
Stage a durable Gorilla Arms installation. |
Open77.cyberware.remove |
(playerId, options) |
players.cyberware.manage |
Stage a durable Gorilla Arms removal. |
Open77.cyberware.cancel |
(playerId, ticket) |
players.cyberware.manage |
Cancel an owned installation/removal before durable commit. |
Open77.cyberware.lease |
(playerId, definition, grade, options?) |
players.cyberware.temporary |
Stage a temporary implant without overwriting paid state. |
Open77.cyberware.releaseLease |
(playerId, lease) |
players.cyberware.temporary |
Release this resource's temporary loadout and restore paid arms. |
Open77.motion.current |
(playerId) |
players.motion.read |
Read an authoritative living-motion lease. |
Open77.motion.knockdown |
(playerId, options) |
players.motion.control |
Request bounded native living-player motion. |
Open77.motion.cancel |
(playerId, id) |
players.motion.control |
Cancel this resource's authoritative motion lease. |
newOperationId() returns a string; definition/identity/manage calls return
{ok=true,error=nil,ticket?,lease?}. install and remove require
options={expectedRevision=record.revision,operationId=retainedOperationId}.
Retain the same ID and contents across retries. Correlate
onCyberwareOperationCompleted(playerId,ticket,encodedResult) with the pending
player and ticket; decode the JSON result and check ok/error. A queued ticket
is not a committed purchase or visual proof. A completed idempotent retry may
return success without a new ticket. Do not refund submitted storage work merely
because native presentation is delayed; cancel can return operation_committing.
current() reads durable {revision,arms,operationId}; effective() substitutes
an active temporary arms snapshot while retaining the durable revision/receipt.
lease accepts durationMs=1000..300000 (default300000), returns
{ok=true,lease,ticket}, and restores paid state on release/expiry/lifecycle loss.
onCyberwareLeaseChanged(playerId,encodedState) carries
id,player,phase,definition,grade,expiresAt,ticket,reason. Wait for matching
active before enabling combat; ended after body loss alone does not prove
paid native restoration. Identity adapters alone select the authenticated user's
character. Restart a stopped definition provider before expecting its persisted
implants to grant combat again.
Open77.motion.knockdown returns {ok=true,id} after admission, not damage or
actual movement. onPlayerMotionChanged(playerId,id,phase,reason) reports
pending, active, ended; the owner native acknowledgement starts the bounded
six-second server ownership window. Requested travel0..6 m is collision-dependent,
not an exact-distance teleport. Cancellation requests cleanup without instantly
freezing momentum. The snapshot validator uses ACK-time bounds rather than the
exact native launch origin. See the runtime-separated searchable cards for
native client projection primitives; server scripts never send local entity
handles over the network.
The related public foundations are
Open77.combat.createScope({bucket,players}) / removeScope(id) with
combat.scope.control, and Open77.effects.attach(target,effect,options),
playOn(target,event,options?), sound(target,event,options?) with world.effects.
Scopes do not bypass team/life/Lua veto rules. Effects use typed network target
IDs and native lifetime guards. sound supports an opaque actionId and
excludePlayers to avoid adding network sound for listeners already hearing the
native contact. These are configurable presentation rules, not extra damage.
Low-level globals backing related namespaced APIs:
| Global | Signature | Permission and result |
|---|---|---|
AttachEffect |
(targetId,kind,effect,slot,localAnchor,localSlot,ttlMs?,radius,hysteresis,localEvent?,soundEvent?,soundOnOwner?) |
world.effects; decimal effect ID or nil/reason. Prefer Open77.effects.attach. |
SetPlayerDownedDamageable |
(playerId,enabled) |
players.stats.apply; boolean/reason. Also exposed as Open77.stats.setDownedDamageable. |
VehicleDrivingCommand |
(operation,vehicleId,options?) |
world.vehicles; driving-state table or nil/reason (state may be nil when absent). Prefer vehicle AI methods. |
Cross-resource exports
Server exports use the same asynchronous publish/call/await surface as client exports, but only reach other server VMs in the same host. See Cross-resource server exports for the complete two-resource example, value limits, permissions, cancellation and reload behavior.
| Function | Signature | Result / purpose |
|---|---|---|
exports |
(name, function) |
Publish or replace a server export; true or nil/reason. Indexed rather than called it is also the synchronous proxy: exports.other:name(...) and exports['other-resource']:name(...) run that export inline and return its values, or raise. Neither exports nor a proxy accepts assignment. |
Open77.exports.callSync |
(resourceName, exportName, ...) |
The same synchronous call without the sugar, for when the export name is in a variable. Raises on failure rather than answering nil, reason; wrap it in pcall. |
GetInvokingResource |
() |
Immediate caller resource name inside an exported coroutine; nil outside it. |
GetInvokingResourceGeneration |
() |
Immediate caller's VM generation inside an exported coroutine; nil outside it. |
GetCurrentResourceGeneration |
() |
Opaque generation identity of this server VM, changed on restart/reload. |
Open77.resource.generation |
(resourceName?) |
This VM's generation when omitted; another running server resource's generation, or 0 if unavailable. |
Open77.exports.call |
(resourceName, exportName, ...) |
Promise for a deferred server export; nil/reason if dispatch is refused. Call only from a running resource, not during preparation. |
Open77.Promise.await |
() |
Use promise:await(): copied return values, or nil/reason on rejection. A pending call requires a managed coroutine. |
Open77.Promise.status |
() |
Use promise:status(): pending, resolved, rejected or cancelled. |
The provider retains its own native permissions and data ownership. Validate
GetInvokingResource() before exposing privileged operations. Arguments never
supply caller identity, and the exported coroutine does not inherit player
source. Exports are the request/response channel; TriggerEvent is the
fire-and-forget broadcast channel and reaches every resource.
Resource-local file IO
Open77.io is server-only persistent text storage rooted at the calling resource's
data/ directory. Paths are relative to that directory; absolute paths, .. escapes and
reparse-point traversal are rejected. The Lua standard io library remains removed from the
sandbox. This API cannot read another resource, the server configuration, or rewrite its own
manifest and scripts.
| Function | Permission | Result / purpose |
|---|---|---|
Open77.io.read |
filesystem.read |
(path) -> contents or nil, reason. |
Open77.io.readJson |
filesystem.read |
(path) -> value or nil, reason; decodes with the bounded Open77 JSON codec. |
Open77.io.exists |
filesystem.read |
(path) -> boolean or nil, reason. |
Open77.io.list |
filesystem.read |
(directory?) -> { { name, type, size? }, ... } or nil, reason; non-recursive and sorted. |
Open77.io.stat |
filesystem.read |
(path) -> { name, type, size?, modifiedUtc } or nil, reason. |
Open77.io.write |
filesystem.write |
(path, contents) -> boolean, reason?; atomically replaces a UTF-8 text file. |
Open77.io.writeJson |
filesystem.write |
(path, value) -> boolean, reason?; encodes then writes atomically. |
Open77.io.append |
filesystem.write |
(path, contents) -> boolean, reason?. |
Open77.io.makeDirectory |
filesystem.write |
(path) -> boolean, reason?; creates missing parents. |
Open77.io.remove |
filesystem.write |
(path) -> boolean, reason?; removes one file, never a directory tree. |
Open77.io.move |
filesystem.write |
(source, destination, overwrite?) -> boolean, reason?. |
Open77.io.copy |
filesystem.read + filesystem.write |
(source, destination, overwrite?) -> boolean, reason?. |
Each file is limited to 4 MiB, a directory listing to 2,048 entries, and a relative path to
512 UTF-8 bytes. readJson / writeJson additionally inherit the JSON codec's 48 KiB and depth
limits. Stable refusals include permission_denied:filesystem.read,
permission_denied:filesystem.write, invalid_path, path_outside_resource,
reparse_point_denied, not_found, not_a_file, not_a_directory, already_exists,
file_too_large, too_many_entries, and io_error.
local course = assert(Open77.io.readJson("courses/watson-loop.json"))
course.revision = (course.revision or 0) + 1
assert(Open77.io.writeJson("courses/watson-loop.json", course))
Resource key/value store
Open77.kvp is the small typed store between "parse a file yourself" and "stand up MariaDB": a
jail timer, a shop's till, a vote count. The surface is the client store's, function for function
and reason token for reason token, so a shared script behaves the same wherever it runs.
| Function | Signature | Result |
|---|---|---|
Open77.kvp.set |
(key, value) |
true, or false, reason. Values are string, integer, number or boolean. |
Open77.kvp.get |
(key, default?) |
The stored value, else default, else nil. |
Open77.kvp.has |
(key) |
boolean. |
Open77.kvp.delete |
(key) |
true when removed, false when absent. |
Open77.kvp.find |
(prefix?, limit?) |
Array of { key, value, type }, ordinal-sorted. |
Open77.kvp.keys |
(prefix?, limit?) |
The same, keys only. |
Open77.kvp.clear |
(prefix?) |
How many were removed. |
Open77.kvp.increment |
(key, delta?) |
The new number. |
Open77.kvp.setIfAbsent |
(key, value) |
true when it inserted. |
Open77.kvp.compareAndSet |
(key, expected, replacement) |
true when it exchanged. |
Open77.kvp.stats |
() |
{ entries, bytes, maximumEntries, maximumBytes, maximumKeyBytes, maximumValueBytes, resource } |
SetResourceKvp / GetResourceKvp / DeleteResourceKvp are the same three bare globals the
client publishes. FiveM's typed variants (SetResourceKvpInt, GetResourceKvpFloat, ...) are
deliberately absent: each is one line of Lua in a porting shim, and spelling the two Open77
runtimes identically is worth more than matching FiveM on a side that never had the API.
It takes no capability, and that is the point. The store is one JSON file under the calling
resource's own data/ directory and the file name is a constant: no caller-supplied path reaches
it, so a resource cannot address another's store. That is strictly less reach than Open77.io,
which does take a path and does sit behind filesystem.read / filesystem.write. Gating the safer
surface would only push authors toward the more dangerous one.
Quotas are the client's numbers exactly β 256-byte keys, 64 KiB values, 4,096 entries, 1 MiB of
payload β because "the server happened to allow a larger value" is a bug that only appears in
production. Refusals: invalid_key, value_too_large, invalid_number, unsupported_value_type,
nil_value, entry_quota_exceeded, byte_quota_exceeded, invalid_limit, delta_not_numeric,
value_not_numeric, integer_overflow, number_overflow, storage_read_failed,
storage_write_failed, storage_corrupt. A refused write leaves the store exactly as it was.
Three properties worth knowing before you rely on it:
- Integer and number stay apart. A counter read back from disk is still an integer, and
compareAndSetcompares typed β an integer1does not satisfy an expectation written1.0. incrementandcompareAndSetare atomic in the sense that matters here: the server Lua runtime is one thread, so a read-modify-write cannot interleave with another resource's.- Writes are coalesced onto the tick and flushed again when the resource stops, so an orderly
stop, reload or restart loses nothing and a hard kill loses at most one tick of mutations. A
store that cannot be parsed reports
storage_corruptand is never overwritten with a fresh one, so a bad file is a problem you can look at rather than state you have already lost.
-- Claim a jail cell without a database, and count the arrest.
if Open77.kvp.setIfAbsent("cell.3", GetPlayerIdentifier(source)) then
Open77.kvp.set("cell.3.releaseAt", GetUnixTime() + 600)
Open77.kvp.increment("stats.arrests")
end
-- Release it only if we are still the occupant.
Open77.kvp.compareAndSet("cell.3", GetPlayerIdentifier(source), nil)
Reach for Open77.database instead when the data must be queried, joined, shared between servers,
or survive the resource directory being redeployed.
Convars
GetConvar, GetConvarInt, GetConvarBool and SetConvar exist so a resource ported from FiveM
runs without its configuration layer being rewritten first. They are a compatibility shim, and
Open77.tunables above is the better mechanism on every axis that matters: a tunable is declared
with a type and a range, validated on every write, retunable by the operator from the Warden panel
while the server runs, persisted across a restart, scoped to the declaring resource, and it tells
its owner when it changed. A convar is an untyped string with none of that.
Reach for a convar when you are porting. Reach for a tunable when you are writing.
| Function | Signature | Result |
|---|---|---|
GetConvar |
(name, default?) |
The string, or default. |
GetConvarInt |
(name, default?) |
The integer, or default. |
GetConvarBool |
(name, default?) |
The boolean, or default. |
SetConvar |
(name, value) |
true, or false, reason. |
Open77.convars.get / getInt / getBool / set are the same four function values under namespaced
names. None of them takes a capability.
A getter never raises. An unset convar, or one holding a value that does not parse as the type asked for, answers the caller's default. A ported script depends on that without ever saying so, and a strict parse would turn a missing setting into a crash on the first frame.
Resolution order, and the third step is the useful one:
- an in-memory
SetConvaroverride, server-wide β a write in one resource is visible to a read in another, which is what the FiveM code being ported assumes, and exactly why a convar is a poor place for anything two resources might both want to own; - the
convarsblock ofserver.jsonc; - this resource's own declared tunables, by exact key. A port that reads
GetConvar("patrol_radius")finds the operator's tunable of that name, so a resource migrates to the better mechanism one key at a time instead of all at once; - the caller's default.
// server.jsonc
"convars": {
"sv_hostname": "Night City RP",
"sv_maxPlayers": "48",
"sv_scriptHookAllowed": "false"
}
SetConvar is in memory only: the value is gone on the next restart and the configured block
comes back. Making a write durable would mean writing to the operator's tracked configuration file
from Lua, which is precisely the power tunables took the trouble to confine to a validated
declaration. Keep credentials out of the block β server.jsonc is tracked, and every other secret
in this schema lives in an environment variable.
Limits: 512 overrides, 4,096-byte values, names matching [A-Za-z0-9_.:-] up to 128 bytes, matched
case-insensitively as FiveM matches them. Refusals: invalid_convar_name,
invalid_convar_value, convar_value_too_long, convar_limit.
Runtime and resource control
What an admin panel needs: run a command, see what commands exist, see what resources exist, and start, stop or restart one.
| Function | Capability | Result |
|---|---|---|
Open77.runtime.executeCommand |
runtime.commands |
Queue one console line. true, or false, reason. |
Open77.runtime.commands |
β | Every registered command, with its owner and source. |
Open77.resource.list |
β | Every discovered resource and its state. |
Open77.resource.metadata |
β | One resource's manifest, or one field of it. |
Open77.resource.start / stop / restart |
resources.control |
Queue a lifecycle verb. |
ExecuteCommand, GetRegisteredCommands, GetResourceMetadata, StartResource, StopResource,
GetNumResources and GetResourceByFindIndex are the FiveM spellings of the same functions.
Runtime control permissions
runtime.commands grants a plain resource's authority. A resource holding it may invoke another
resource's ordinary command β reusing something a sibling already wrote, which is the common and
harmless case β and nothing else. A command declared restricted is refused, and so is every
resource-lifecycle verb.
resources.control grants the operator's. It raises a queued line to the authority the in-game
operator escalation already uses, and it is separately required by start, stop and restart.
This is the admin-panel capability.
FiveM's ExecuteCommand runs as the console β rcon authority, with nothing anywhere declaring that
a resource holds it. That is the behaviour deliberately not copied. Here the manifest says it, so an
operator installing a resource sees the escalation before running it, and a resource that only
wanted to call a sibling's command never gets the lifecycle verbs at all.
The escalated surface is also narrower than rcon. It reaches the resource lifecycle and any Lua command; it does not reach the ACL, ban, routing-bucket or phantom verbs, which live in a different dispatcher the scripting host has no handle on.
list and metadata take no capability. They report the operator's own installation β which
resources exist, their version, their declared permissions β which any resource can already infer
from exports and dependency declarations. The power in this area is the control verbs.
Everything here is queued
Lua runs inside the host's tick. A resource stopping itself synchronously would free the Lua state doing the stopping, and dispatching a command synchronously would re-enter a VM and reset an instruction budget mid-flight β the accounting the whole sandbox rests on. So a line is queued and drained at the next tick boundary, on the game-loop thread and outside every VM, which is exactly where a line typed at the console is dispatched.
These calls therefore report acceptance, not completion. Read Open77.resource.list() on a
later tick for the outcome; the reply to a queued line goes to the server log, naming the resource
that asked, because a resource quietly restarting another one is something an operator reading a log
must be able to see. At most 64 lines may be queued in one tick; the 65th answers
command_queue_full.
-- An admin panel's refresh.
for _, entry in ipairs(Open77.resource.list()) do
print(entry.name, entry.version, entry.state)
end
if Open77.resource.metadata("garage", "version") ~= "1.0.0" then
assert(Open77.resource.restart("garage")) -- queued; check the list next tick
end
Open77.runtime.commands() spans every running VM plus the resource-lifecycle verbs the platform
owns, distinguished by the source field (resource or console). The ACL, ban, routing-bucket
and phantom console verbs are not listed, because they live in a hand-written dispatcher with no
registry to read. That is a known gap rather than a silent omission, and the source field is what
tells a reader this list has two origins.
Join-time readiness gate
The barrier that stops one resource acting on a player another resource is not finished with.
The host owns the combined readiness barrier across every participant and VM
generation; asynchronous server exports do not replace this lifecycle contract.
Full rationale and worked example in
docs/lua-resources.md.
The rule: do not teleport, spawn, kill or force a respawn on a player until their gate has opened. Reading state, rosters and HUD payloads are unaffected.
| Function | Signature | Result / purpose |
|---|---|---|
Open77.ready.isReady |
(playerId) |
Whether anything is still holding this player. A player the host does not know is ready. |
Open77.ready.participate |
({ timeoutMs?, reason? }) |
Declare once, at load: every player who connects from now on arrives with one hold in this resource's name. |
Open77.ready.hold |
(playerId, reason?) |
Take or refresh this resource's hold. Returns the player's session, or nil, reason. |
Open77.ready.release |
(playerId, session?, note?) |
Clear this resource's hold. A session that no longer matches is dropped. |
Open77.ready.status |
(playerId) |
{ known, ready, session, ageMs, holds = { { resource, reason, ageMs, remainingMs } } }. |
onPlayerReady(playerId, detail) is emitted into every running resource when the last hold
clears. playerId arrives as a string like every host event. detail is cleared, no_holds,
resource_reloaded, resource_stopped, or timeout:<resource>.
It is a barrier lifting, not a trigger: it says nothing about whether the player's world is up, so
keep your own readiness signal and ask the gate for permission. Every hold carries a deadline, so a
resource that never releases costs one degraded join and one WRN naming it, never a stuck server.
ready at the console lists who is holding whom. No permission is required.
What a connection can tell you
Three reads about the link itself rather than the character on the end of it.
| Function | Permission | Signature | Purpose |
|---|---|---|---|
Open77.players.ping(playerId) |
None | -> milliseconds | nil, reason |
Round-trip latency, measured by the server on its own transport rather than reported by the client. Not gated: latency is not identity, every scoreboard in the genre shows it, and a resource denied it would simply time a round trip of its own. |
Open77.players.endpoint(playerId) |
players.identity.sensitive |
-> "ip:port" | nil, reason |
The remote address. This is a player's IP address. |
Open77.players.identifiers(playerId) |
None, plus players.identity.sensitive for endpoint |
-> table | nil, reason |
open77 and userId (the same installation identity GUID), name, fingerprint, joinedAt, plus verified license, steam and gog when available since 2.31.13+op77.101. license is the permanent Open77 account GUID without dashes; steam is lowercase hexadecimal and gog decimal. All are strings without type prefixes. endpoint requires the capability. See account and store identifiers. |
GetPlayerIdentifierByType(playerId, type) |
None | -> string | nil, reason |
Bare identifier, with case-insensitive type: open77, userId, name, fingerprint, license, steam, gog. Missing ownership fields return identifier_not_linked; unsupported types return unknown_identifier_type; non-string types return invalid_identifier_type. |
GetPlayerIdentifiers(playerId) |
None | -> array | nil, reason |
Available identifiers as type:value strings, including license:, steam: and gog: since 2.31.13+op77.101. Missing entries are omitted. Unknown session: player_not_found. |
GetPlayerPing |
None | (playerId) -> milliseconds | nil, reason |
FiveM's spelling of Open77.players.ping, the same function under both names. |
GetPlayerEndpoint |
players.identity.sensitive |
(playerId) -> "ip:port" | nil, reason |
FiveM's spelling of Open77.players.endpoint, the same function under both names. |
Open77.players.sessionStats(playerId) |
None, plus players.identity.history for the history half |
-> table | nil, reason |
The live session: playerId, userId, connectedAtUtc (the same JoinedAtUtc identifiers reports as joinedAt), sessionSeconds, lastSeenUtc. With the capability, also firstSeenUtc, previousSeenUtc, joinCount and totalPlaySeconds from the identity directory the server already persists -- absent without it, never nil-filled. |
Open77.players.lastSeen(identifier) |
players.identity.history |
-> table | nil, reason |
When a durable identifier was last on this server, online or not: userId, name, online, lastSeenUtc, firstSeenUtc, previousSeenUtc?, joinCount, totalPlaySeconds, plus the live fields while connected. identity_unknown for somebody never admitted here, history_unavailable on a server without a directory. |
GetPlayerTimeOnline |
None | (playerId) -> milliseconds | nil, reason |
FiveM's spelling of the live session length, in the milliseconds FiveM documents. |
GetPlayerLastMsg |
None | (playerId) -> milliseconds | nil, reason |
FiveM's spelling of the age of the newest packet the server holds for the player -- the rich read's ageMs. no_packet_yet before the first snapshot. |
Open77.players.locale(playerId) |
players.locale.read |
-> { code, gameLanguage, voiceLanguage, subtitles, chromium, ageMs, revision } | nil, reason |
The language the player's own game runs in, as their client reported it at world-ready: code a BCP-47 tag (en-US, fr-FR, pt-BR), the three raw engine codes beside it. not_reported until the report lands. See identity.md. |
GetPlayerLocale |
players.locale.read |
(playerId) -> table | nil, reason |
FiveM's spelling of Open77.players.locale, the same function under both names. |
Two things about this surface are deliberate and worth knowing before you build on it.
A transport that cannot measure latency says so. ping reads the live connection, so it
answers nil, "ping_unavailable" rather than 0 whenever there is nothing to read -- a
transport that cannot measure at all, which is the case for the phantom transport the bots run
on, or one that has not sampled yet. That is a different refusal from player_not_found and
from invalid_player_id, and the three are kept apart on purpose: a bare nil collapses them
into "no". A zero would be indistinguishable from a perfect link, so the surface never produces
one it did not measure.
endpoint is absent from identifiers, not nil, without the capability. A caller that
holds players.identity.sensitive gets the field; one that does not gets a table with no
endpoint key at all. That is the shape the rich player read already uses, and it lets a
resource tell "you may not ask" apart from "there is nothing there" -- a distinction a
nil-filled field destroys.
Connection control
Who gets in, decided in Lua. The guide, with a whitelist and a ban list to copy, is Connection control.
| Event / function | Permission | Signature | Purpose |
|---|---|---|---|
onPlayerConnecting |
players.gate |
(player, deferrals) |
Emitted for every hello that passed the platform checks, before a player id exists. player = { userId, name, publicKey, fingerprint, ticket }. Return to accept; deferrals.defer() to hold, deferrals.update(message) to log progress, deferrals.done() to release, deferrals.done(message) to refuse with that text on the player's screen (127 bytes of UTF-8). One refusal wins; every participating resource must release to admit. |
onPlayerRejected |
None | (userId, name, code, message) |
Every refused connection, platform or gate. code is the lowercased reject reason (protocolmismatch, gamebuildmismatch, invalidhello, serverfull, duplicatehello, refused); message the machine token (server_full, identity_proof_invalid, connect_ticket_required, connection_gate_timeout, ...) or the gate's own text. userId and name are empty when the hello could not be read. |
onPlayerConnected |
None | (playerId, playerName) |
An admitted player has a session. Not an incarnation: see the readiness gate above before acting on them. |
onPlayerDisconnected |
None | (playerId, reason) |
reason is connection_closed, or the text given to disconnect, kick or ban. |
playerConnecting |
players.gate |
(name, setKickReason, deferrals) |
The same gate as onPlayerConnecting, under the FiveM name and with the FiveM argument order, so a ported whitelist runs unchanged. It votes in the same tally; a resource may register either name or both. deferrals is the same table. source is not set: no player id exists yet, and the connection token is not one. setKickReason(message) records a message and refuses nobody β the same as in FiveM, where the refusal comes from CancelEvent. Open77 has CancelEvent, but it only means something inside a TriggerCancellableEvent dispatch, and the connection gate is not one: called here it answers false, "not_in_cancellable_event". Refuse with deferrals.done(message). The runtime says this once per resource in the log the first time setKickReason is called. |
playerJoining |
None | (oldId) with source |
FiveM's join event, emitted when a player is assigned a session, and seen by a resource before onPlayerConnected. source is the new player id. oldId is always the empty string: FiveM puts the temporary connection id there, and an Open77 player is given one id and keeps it. |
playerDropped |
None | (reason) with source |
FiveM's leave event, emitted for every disconnection beside onPlayerDisconnected. source is the player who left; reason is the same text onPlayerDisconnected receives. Both arrive in the same tick and either is a complete account of the leave β do not sequence work across the two. |
onPlayerLifeStateChanged |
None | (playerId, revision, phase, reason) |
The authoritative life state machine moved. phase is alive, dead, revivepending, respawnpending or recovering; revision increases per player and lets you drop an out-of-order notification; reason is the token that caused the move (registered, restored, resync_requested, transition_timeout, or the reason given to kill, revive or respawn). |
onPlayerLifeTransitionFailed |
None | (playerId, revision, reason) |
A life transition was not applied. reason is timeout when the client never acknowledged, otherwise the lowercased client result. The player keeps the phase they had. |
Open77.players.identity / GetPlayerIdentity |
None | (playerId) |
{ userId, name, publicKey, fingerprint, joinedAt, license?, steam?, gog? } for an admitted player, or nil. Ownership fields come from the verified Master ticket since 2.31.13+op77.101. |
Open77.time.unix / GetUnixTime |
None | () |
Wall-clock seconds since 1970 UTC, fractional. For expiring bans; the sandbox has no os. |
Open77.time.utc / GetUtcTimestamp |
None | () |
The same instant as an ISO 8601 string. |
Open77.access.status |
players.access |
() |
The server's built-in door list, shared with Warden and the console: { whitelistEnabled, whitelist = { { userId, label, addedAt, addedBy } }, bans = { { userId, name, reason, bannedAt, expiresAt?, bannedBy } } }. |
Open77.access.setWhitelist |
players.access |
(enabled) |
Switch the built-in whitelist. true, or false, reason. |
Open77.access.allow / disallow |
players.access |
(userId, label?) / (userId) |
Add an identity to the built-in whitelist, or take it off. |
Open77.access.isAllowed / isBanned |
players.access |
(userId) |
Read the built-in list. |
Open77.access.ban |
players.access |
(userId, reason?, seconds?, name?) |
Ban an identity on the built-in list (seconds nil or 0 = permanent) and disconnect them if online. |
Open77.access.unban |
players.access |
(userId) |
Lift a built-in ban. |
The gate answers within simulation.connectGateTimeoutSeconds (default 8, allowed 0.5 to 9); a
gate that never answers refuses the player with connection_gate_timeout. A handler error is
logged and counts as an acceptance. A resource with a handler but without players.gate is
ignored with one WRN.
Who can see whom is a separate pair of events, onPlayerEnteredScope / onPlayerLeftScope
(and FiveM's playerEnteredScope / playerLeftScope): see Scope events under
routing buckets, because scope is the bucket narrowed to the interest radius.
Operator-tunable settings
Settings a server owner may retune from the Warden admin panel while the server runs, without a restart. The resource declares what is tunable; the panel can only move values inside that declaration, and never edits Lua. Server-side only.
| Function | Signature | Result / purpose |
|---|---|---|
Open77.tunables.declare |
(table) |
Declare this resource's tunables and return a live proxy. Raises if the declaration is malformed. |
Open77.tunables.get |
(key) |
The current value, or nil, reason for an undeclared key. |
Open77.tunables.set |
(key, value) |
Write one. Returns ok, message, pending. Same validation and persistence as a panel write. |
Open77.tunables.capture |
() |
A frozen plain table of every current value, to pin onto one match. |
Open77.tunables.pending |
() |
Key β value for changes waiting on a boundary. |
Open77.tunables.promote |
() |
Adopt everything waiting; returns the list of keys that moved. |
Declaration fields: value (required default), type (number, integer, boolean, string,
enum), min, max, step, choices, unit, apply (live, next_round, next_match),
label, description, group, order. min/max/type/choices are enforced on every write;
step and unit are presentation only. Limits: 128 tunables per resource, 64 KiB of declaration,
32 choices, 256-character strings.
The proxy returned by declare reads through to the host on every access. Read it at the point
of use; a value hoisted into a file-scope local is captured at load time and never updates, while
the panel goes on reporting the new number. An undeclared key raises rather than returning nil.
Values are owned by the host, so they survive a reload and a stop, and come back after a
restart from tunables.json next to server.jsonc. A key declared next_round or next_match is
stored and persisted immediately but does not reach Open77.tunables.get until the resource calls
promote(); for a mode running several rounds at once, capture() per match is the only correct
form. The owning resource β and no other β receives onTunableChanged(key, value, pending) after
every accepted write.
Notifications
These methods route to the official open77_notifications client package. IDs and mutation rights
are isolated per calling server resource.
| Function | Signature | Result |
|---|---|---|
Open77.notifications.send |
(playerId, definition) |
Owner-local notification ID, or nil, reason. |
Open77.notifications.broadcast |
(definition) |
Owner-local broadcast ID, or nil, reason. |
Open77.notifications.update |
(id, patch) |
boolean, reason? |
Open77.notifications.dismiss |
(id) |
boolean, reason? |
Open77.notifications.clear |
(playerId?) |
Clear this owner's notifications for one player or everyone. |
See notifications for every definition field and queue limit.
Perspective policy
Whether players on this server may use third person. The calling server resource must declare
network.events; the policy is broadcast to every client and answered again to anyone who joins
later.
| Function | Signature | Result |
|---|---|---|
Open77.perspective.setPolicy |
(policy, perspective?) |
true, or false, reason. |
Open77.perspective.policy |
() |
policy, perspective, declared β what this resource set. |
policy is disabled, allowed, default or forced; perspective is fpp or tps and is
required for forced, because a pin with nothing pinned would quietly become first person.
The client arbiter ranks a server policy above the player's own preference and every world state
above both. A forced policy does not erase what the player asked for: lifting the pin returns
them to it.
Call setPolicy from the resource's start path β a reload replaces the VM, and the policy
lives in it. A server on which no resource ever calls this sends nothing and every client keeps the
platform default allowed. If two resources declare a policy, both answer a joining client and the
last write wins; one owner, normally the gamemode, is the remedy.
See docs/perspective.md for the priority table, the player-facing key and
preference, and the measured limitations.
Synchronized RP animations
Open77.animations supplies server-owned player actions. See the
RP guide and complete profile/clip catalogue
for ownership, sequence durations, events, local TPP and development validation status.
| Method | Signature | Permission / result |
|---|---|---|
Open77.animations.list |
(query?) |
No permission; profile array. |
Open77.animations.get |
(profileId) |
No permission; profile or nil. |
Open77.animations.play |
(playerId, profileId, options?) |
players.animations.control; accepted state. |
Open77.animations.playAt |
(playerId, profileId, position, yaw?, options?) |
players.animations.control; place a nearby player at a world pose and start the profile, returning accepted state with playbackId and anchor. |
Open77.animations.sequence |
(playerId, steps, options?) |
players.animations.control; accepted state. |
Open77.animations.stop |
(playerId, playbackId?) |
players.animations.control; true or nil/error. |
Open77.animations.stopAt |
(playbackId) |
players.animations.control; stop an owned placed action by its playback handle. |
Open77.animations.current |
(playerId) |
players.animations.read; active state or nil. |
Open77.animations.clip |
(clip) |
No permission; the profile that owns a clip name, or nil. |
Open77.animations.clips |
(query?) |
No permission; every addressable clip as { clip, profile }. |
Open77.animations.playClip |
(playerId, clip, options?) |
players.animations.control; play addressed by clip name, the way TaskPlayAnim is. |
play and sequence return nil, error on rejection. An accepted server action
does not prove native rendering has started. Client methods are a separate,
Promise-based self-request API; do not use their signatures on the server.
Cyberware
The authoritative half of the cyberware API. It separates definition, identity, management, temporary loadouts and reads into five permissions, because the resource that defines an implant, the one that sells it and the one that reads it are rarely the same resource.
| Function | Permission | Signature | Result / purpose |
|---|---|---|---|
Open77.cyberware.define |
players.cyberware.define |
(definition) |
Register a definition for this resource. arms/gorilla_arms and legs/double_jump coexist. |
Open77.cyberware.bind |
players.cyberware.identity |
(playerId, characterKey) |
Begin loading this authenticated user's selected character. |
Open77.cyberware.unbind |
players.cyberware.identity |
(playerId) |
Release this resource's binding and its runtime projection. |
Open77.cyberware.current |
players.cyberware.read |
(playerId) |
The committed record, or nil while loading or projecting. |
Open77.cyberware.effective |
players.cyberware.read |
(playerId) |
The record gameplay should use, including an active temporary loadout. |
Open77.cyberware.activity |
players.cyberware.read |
(playerId) |
The live projector observation β charge, hold, phase β or nil. |
Open77.cyberware.leaseState |
players.cyberware.read |
(playerId) |
The temporary lease, or nil when none remains. |
Open77.cyberware.lease |
players.cyberware.temporary |
(playerId, definition, grade, options?) |
Stage a temporary loadout; {ok=true,lease=β¦,ticket=β¦}. |
Open77.cyberware.releaseLease |
players.cyberware.temporary |
(playerId, leaseId) |
Release it and restore the paid-for state. |
Open77.cyberware.newOperationId |
players.cyberware.manage |
() |
A fresh 32-character durable operation ID; retain it across retries. |
Open77.cyberware.install |
players.cyberware.manage |
(playerId, definition, grade, options?) |
Stage an installation; completion is asynchronous. |
Open77.cyberware.remove |
players.cyberware.manage |
(playerId, options?) |
Stage a removal; defaults to arms, accepts options.slot="legs". |
Open77.cyberware.cancel |
players.cyberware.manage |
(playerId, ticket) |
Cancel this resource's staging and request rollback. |
current is what the player owns; effective is what they are currently running. A lease changes
the second and never the first, which is what lets an arena hand out an implant without charging
anyone or rewriting a durable record.
Install and remove complete asynchronously. Pass one options.operationId from
newOperationId() and reuse it on every retry β that is what makes the operation idempotent across
a retry, a reload or a reconnect. cancel answers operation_committing once the SQL commit has
begun: past that point the operation is going to land, and cancelling is no longer meaningful.
Cyberware events, queued into every running server resource:
| Event | Arguments | Meaning |
|---|---|---|
onCyberwareOperationCompleted |
(playerId, ticket, encodedResult) |
A staged install or remove finished. ticket matches the one the staging call returned. |
onCyberwareLeaseChanged |
(playerId, encodedState) |
A temporary loadout was granted, replaced or released. |
onCyberwareJump |
(playerId, encodedResult) |
{sequence, ok, error} β second-jump admission, not native movement completion. |
onCyberwareMeleeHit |
(victimId, attackerId, encodedHit) |
An accepted Gorilla contact: {sequence, incarnation, instanceId, definition, grade, charged, amount, bodyPart, lethal}. Zero-knockback and cosmetic contacts still emit. |
onCyberwareMotionOutcome |
(victimId, attackerId, encodedOutcome) |
What the hit's knockback did: outcome is pending, rejected or skipped, with a reason such as lethal, downed, zero_distance, bucket_mismatch or motion_unavailable. |
onCyberwareMeleeBlocked |
(victimId, attackerId, sequence, amount) |
A contact the victim blocked. |
onCyberwareActionRejected |
(playerId, sequence, reason) |
Server admission refused a swing: insufficient_stamina, cooldown, charge_expired or not_charged. Repeats of the same rejection are suppressed. |
encodedResult, encodedHit, encodedState and encodedOutcome arrive as JSON strings; decode
them with json.decode. Every payload describes what the server admitted, never what a client
rendered. The open77:cyberware:* projection namespace is platform-owned and cannot be forged with
public Lua event dispatch.
Forced motion
Experimental. Open77.motion asks the native layer to move a player's body without teleporting it:
the server admits the request and the owning client performs it.
| Function | Permission | Signature | Result / purpose |
|---|---|---|---|
Open77.motion.knockdown |
players.motion.control |
(playerId, { x, y, distance, durationMs? }) |
Request a planar knockdown; {ok=true,id=β¦}, or nil, reason. durationMs (1-3000) ends the active lease that many milliseconds after the owner reports the body down, with reason = "duration_elapsed"; absent or 0 keeps the native three seconds. |
Open77.motion.cancel |
players.motion.control |
(playerId, id) |
Cancel a request this resource owns. |
Open77.motion.current |
players.motion.read |
(playerId) |
The current record, or nil. |
The direction is normalised by the backend and the distance is bounded to 0β6 metres. Native physics decides the actual displacement: this is not an exact-distance teleport, and an accepted request is not proof of movement. The body must be ready, alive, unmounted and in its original incarnation and bucket; overlapping requests are refused.
current returns id, player, incarnation, bucket, directionX, directionY, distance,
createdAt, expiresAt and phase, with times in server-monotonic milliseconds.
onPlayerMotionChanged(playerId, id, phase, reason) reports the three phases. Admission starts
pending; active means the owner acknowledged the native observation, not that the server saw
an animation or a displacement, and it opens a bounded six-second reaction and recovery window;
ended closes the record. Cancellation and resource stop both send an ended record and release only
the requesting resource's native ownership.
The client half β Open77.motion.knockdown, state and stop β is a different API in a different
runtime with different arguments; see the generated client reference.
Player clothing
These asynchronous methods target the official open77_equipment client relay. The calling server
resource must declare network.events. Request IDs are prefixed with the resource name, and only
that resource VM can match the completion.
| Function | Signature | Result |
|---|---|---|
Open77.clothing.equip |
(playerId, record, options?) |
Request ID, or nil, reason. |
Open77.clothing.unequip |
(playerId, slot) |
Request ID, or nil, reason. |
Open77.clothing.set |
(playerId, wardrobe, options?) |
Request ID, or nil, reason. |
Open77.clothing.clear |
(playerId) |
Request ID, or nil, reason. |
Open77.clothing.requestSnapshot |
(playerId) |
Request ID, or nil, reason. |
Listen for the resource-local open77:clothing:completed event. Its arguments are
playerId, requestId, operation, accepted, reason, result. Dispatch failures are returned
immediately; unanswered requests complete with request_timeout after 10 seconds. Full record,
slot and result semantics follow the relay's equipment registry. set accepts a
slot-to-record table, for example {outer_chest="Items.Jacket_01_basic_01"};
false empties a slot and omitted slots stay unchanged. A snapshot returns rows
with slot, attachmentSlot, equipped, and optional record/tweakDbId.
These writes follow normal presentation persistence; completion alone is not
proof of observer appearance. An active wardrobe outfit can hide equipment.
See the docs/research/clothing-items-catalog.md.
Wanted level and NCPD dispatch
| Function | Signature | Result |
|---|---|---|
Open77.players.setWanted |
(playerId, level) |
{ level, requested, maxLevel }, or nil, reason. |
Open77.players.setMaxWanted |
(playerId, level) |
{ level, maxLevel }, or nil, reason. |
Open77.players.getWanted |
(playerId) |
{ level, maxLevel, enabled }, or nil, reason. |
Open77.world.setPreventionEnabled |
(bucket, enabled) |
{ bucket, enabled }, or nil, reason. |
Levels run 0 to 5. setWanted(id, 0) is ClearPlayerWantedLevel. The host event
onPlayerWantedChanged(playerId, level, previous) fires when the value changes.
-- open77.lua: permissions { "players.wanted", "players.wanted.read", "world.prevention" }
Open77.players.setMaxWanted(playerId, 3)
local applied = Open77.players.setWanted(playerId, 5)
print(applied.requested, applied.level) -- 5 3 (the ceiling was applied here)
AddEventHandler("onPlayerWantedChanged", function(player, level, previous)
print(("player %d: %d -> %d"):format(player, previous, level))
end)
Open77.world.setPreventionEnabled(0, false) -- no dispatch anywhere in bucket 0
Read this before building a chase on it
The engine has no per-player wanted level. Measured on 2.31, and it is not a
detail: PreventionSystem holds ONE heat stage, for the one player its client is
running. ChangeHeatStage(stage, reason), GetHeatStage() and
SetMinMaxResetHeatLevels(min, max, isDefault) take no target, the state is
scalar on a singleton system, that system holds exactly one player handle, and
every consumer β the wanted bar, sense components, vehicles, minimap pins β reads
one global UI_WantedBar blackboard.
A per-player number exists here only because every Open77 player runs their own game: the server keeps the value and tells each client its own. Three consequences follow, and none of them is visible in the shape of the API:
- The police are local. The NCPD units a heat stage spawns belong to the client that spawned them. They are not Open77 entities, they are not replicated, and no other player sees them. Two wanted players standing together are two private pursuits.
- The server never reads the game. There is no value channel back from
REDscript, only a per-command acknowledgement, so
getWantedandonPlayerWantedChangedreport a decision this server made β never an observation. If something in the client's own world clears the heat, the server does not find out. - Population suppression wins. While a client has world population
suppression on,
VehicleSpawnPolicyholds the prevention block flags (SetSystemLock,SetBlockOnFootSpawn,SetBlockVehicleSpawn), so a heat stage would be set and no unit would ever arrive. The client refuses the raise withpopulation_suppressedrather than reporting a success nobody can see. Clearing to 0 is always allowed.Open77.world.setPreventionEnabledis a different switch (TogglePreventionSystem) and deliberately never touches those three flags, so the two cannot fight.
What the vanilla route does that this does not
The game's own path is a SetWantedLevel request object, not a method, and
its fields are private: new SetWantedLevel() and QueueRequest compile, while
every read and write of wantedLevel fails [UNRESOLVED_MEMBER], even from
inside an @addMethod body. So Open77 goes through ChangeHeatStage, and the
request's forceGreyStars, resetGreyStars and
forcePlayerPositionAsLastCrimePoint behaviour is not reproduced.
SetWantedLevelFact is not used either: the fact is something the system
publishes and never reads. Forcing it to zero left the star on screen and the
fact back at zero.
Client resources reach the same levers directly through Open77.prevention
(setWanted, setMaxWanted, setEnabled, state) under the world.prevention
permission. state() is a request record, not an engine reading β its fields
are named requestedLevel, levelAcknowledged and so on for that reason.
Player appearance
A saved face and body, restored on a player without that player asking for it. Relayed exactly like clothing, for the same reason: the addressed client owns the REDengine player object, so the server asks and the client answers.
| Function | Signature | Result |
|---|---|---|
Open77.appearance.capture |
(playerId) |
Request ID, or nil, reason. |
Open77.appearance.apply |
(playerId, snapshot, options?) |
Request ID, or nil, reason. |
The calling resource must declare players.appearance.relay as well as
network.events. That is a permission of its own, not a corner of
player.appearance.edit (a client-side grant over the local player) or of
clothing (garments a player can see and take off again): a snapshot is the output
of the character creator, the closest thing this platform holds to a likeness of
the person playing. A resource that can read one can fingerprint every player on
the server, and one that can write one can make somebody wear a face they did not
choose. Nothing in the relay writes a snapshot to a log.
Listen for the resource-local open77:appearance:relayCompleted event with
playerId, requestId, operation, accepted, reason, result. Request IDs carry both
the resource name and an appearance segment, so an appearance answer can never
complete a clothing or weapon request. Dispatch failures return immediately;
an unanswered request completes with request_timeout after 10 seconds rather
than leaving the caller waiting.
capture answers { snapshot, family, revision, characterKey }. The snapshot is
the network form β schema version, game build, catalogue digest, gender, and the
option identities and values β with the editor's local metadata stripped. It is the
same shape apply takes, so a capture can be stored and handed straight back.
apply does not reach the native layer behind open77_appearance's back. The
snapshot becomes that player's new appearance record β the server's word about
what they wear β and the resource's ordinary reconciliation puts it on the body.
That is the road a stored appearance already takes at world entry, so a relayed
face survives death, a bucket change and a reload instead of being quietly undone
by the next reconcile.
Two limits worth knowing before you build on it:
- It does not change the body family. A different family is a player reload,
not an appearance change, and a caller restoring a face has not asked for that. A
snapshot whose gender disagrees with the body on the player is refused with
body_family_mismatch. - It does not persist. The platform's stored appearance row is untouched; a resource that wants the change to outlive the session owns that persistence.
Refusals from the client are named: appearance_busy (an editor, the character
creator or a fitting-room preview is open), player_not_ready,
appearance_restore_failed, appearance_record_unavailable. Completion reports
apply_timeout when the record was set but the body had not settled within eight
seconds, and appearance_superseded when a newer apply replaced it.
-- open77.lua: permissions { "network.events", "players.appearance.relay" }
local stored = {}
AddEventHandler("open77:appearance:relayCompleted",
function(player, requestId, operation, accepted, reason, result)
if not accepted then print("appearance " .. operation .. " failed: " .. reason); return end
if operation == "capture" then stored[player] = result.snapshot end
end)
Open77.appearance.capture(playerId)
-- ...later, on a fresh session:
Open77.appearance.apply(playerId, stored[playerId])
Player weapons
These asynchronous methods target the official open77_weapons client relay.
The calling resource declares network.events and depends on
open77_weapons >=0.1.0.
| Function | Signature | Result |
|---|---|---|
Open77.weapons.assign |
(playerId, record, slot, options?) |
Request ID, or nil, reason. |
Open77.weapons.setActive / activate |
(playerId, slot) or (playerId, record, options?) |
Request ID, or nil, reason. |
Open77.weapons.remove / unequip |
(playerId, slot) |
Request ID, or nil, reason. |
Open77.weapons.holster |
(playerId) |
Request ID, or nil, reason. |
Open77.weapons.requestSnapshot |
(playerId) |
Request ID, or nil, reason. |
Listen for open77:weapons:completed with
playerId, requestId, operation, accepted, reason, result. Replies are matched
to the authenticated target and calling resource; unanswered requests complete
with request_timeout after 10 seconds. See
Weapon Lua API for options, result schemas, permissions,
errors, and inventory-authority rules.
Reading what a player is carrying
Open77.weapons.get(playerId) (permission player.weapons.read) answers the three slots, the
active slot, whether the weapon is drawn and the magazine. Its low-level alias is
GetPlayerWeapons.
It is a cache of what the owner reported, never authority, and it reports two ages rather
than one because the two halves refresh at different rates. reportedAgeMs dates active,
drawn and magazine, which ride the 20 Hz player snapshot, and carries fresh under the same
two-second rule Open77.players.get applies. loadoutAgeMs dates slots, which is pushed only
when it changes -- minutes there is normal and is not a fault. source separates a report from
a snapshot, and a player nobody has heard from fails weapons_unreported, which is a different
answer from carrying nothing. A clear WeaponValid bit means "not reported", never "unarmed".
Host event onPlayerWeaponChanged(playerId, slot, record, drawn) fires on a change.
Open77.weapons.clear(playerId) empties all three slots as one relay operation rather than three
removals; an already-empty slot is not a failure. Full schemas are in
Weapon Lua API.
Record catalogues
Open77.data.vehicle / weapon / item / npc(record) answer what a 2.31 record is. The low-level
alias behind all four is GetRecordData, called as GetRecordData(kind, record).
A record is a TweakDB string, never a Jenkins hash -- the same choice the Citizen alias
layer already made, and a porting creator should expect it: there is no number to compare and no
GetHashKey to round-trip through. source tells you where the answer came from: catalogue on
the server, tweakdb on a client, which reads the live database and gets the display name in the
player's own language. Open77.data.localize is client-only and fails localization_client_only
on the server rather than hand back a key that could pass for a translation. One asymmetry worth
knowing: server npc() takes an entity template path, client npc() takes a Character.*
record. See Record catalogues.
Held items
An item in one of the body's ten equipment slots, given through the engine's own
transaction system, so it is genuinely held rather than following the body. The
other half of the attachment surface: Open77.props.attach follows, this
holds. These asynchronous methods target the official open77_helditems
client relay; the calling resource declares network.events.
| Function | Signature | Result |
|---|---|---|
Open77.heldItems.hold |
(playerId, record, { slot? }) |
Request ID, or nil, reason. |
Open77.heldItems.release |
(playerId, { slot? }) |
Request ID, or nil, reason. |
Open77.heldItems.slots |
() |
The ten slot names, sorted. |
Open77.heldItems.requestSnapshot |
(playerId) |
Request ID, or nil, reason. |
The slot vocabulary is the ten AttachmentSlots.* equipment records: Head,
Face, InnerChest, OuterChest, Legs, Feet, Outfit, UnderwearTop,
UnderwearBottom, WeaponRight. WeaponRight is the right hand and the
default. What is held must be an item record, not an entity.
Listen for open77:helditem:completed with
playerId, requestId, operation, accepted, reason, result. Unanswered requests
complete with request_timeout after 10 seconds. Failures are
invalid_item_record, invalid_slot, invalid_player, player_not_ready and
whatever the client equipment system refused. Open77.weapons writes the same
WeaponRight slot, so pick one per gamemode. See
props for the full
contract and its limits.
Players and authoritative life
| Function | Permission | Signature | Result / purpose |
|---|---|---|---|
Open77.players.name |
None | (playerId) |
Display name or nil. |
Open77.players.identifier |
None | (playerId) |
Persistent installation identity GUID (userId) or nil; use license from identifiers for account-level persistence. |
Open77.players.identity / GetPlayerIdentity |
None | (playerId) |
{ userId, name, publicKey, fingerprint, joinedAt, license?, steam?, gog? } or nil; the fingerprint matches the client's identity.dump. Ownership fields require 2.31.13+op77.101 or later. |
Open77.players.position |
None | (playerId) |
{ x, y, z, bucket } or nil. |
Open77.players.all / GetPlayers |
None | () |
Array of every authenticated player id, ascending. The host's roster, so a gamemode no longer needs its own copy built from join/leave events. |
Open77.players.inBucket / GetPlayersInBucket |
None | (bucket) |
Ids of the authenticated players whose routing bucket is bucket, ascending. false, invalid_bucket for a negative or oversized bucket. |
Open77.players.positions / GetPlayersPositions |
None | () |
{ [playerId] = { x, y, z, bucket } } for every player with a snapshot younger than two seconds. Players without one are absent, not nil entries. One call replaces a loop of position over the roster. |
Open77.players.get |
None | (playerId) |
Everything the host can answer about one player synchronously, including a dated transform. nil, reason for an unknown player. See The rich read. |
Open77.players.nearby |
None | (anchor, radius?, options?) |
Players within radius of a point or of another player, nearest first, each with its distance. |
Open77.players.closest |
None | (anchor, options?) |
The nearest entry nearby would return, or a bare nil when nobody matches. |
Open77.players.distance |
None | (a, b) |
Metres between two players, two points, or one of each. nil, reason otherwise. |
Open77.players.getVehicleSeat |
world.vehicles |
(playerId) |
Canonical seat assignment or nil. |
Open77.players.warpIntoVehicle |
world.vehicles |
(playerId, vehicleId, seat, options?) |
Authoritatively assign a seat; alias of the vehicle API. |
Open77.players.forceOutOfVehicle |
world.vehicles |
(playerId, vehicleId?) |
Force native exit, overriding exit lock. |
Open77.players.setVehicleExitLocked |
world.vehicles |
(playerId, locked, vehicleId?) |
Set or clear the durable no-exit policy. |
Open77.players.disconnect |
players.disconnect |
(playerId, reason?) |
Queue a reasoned disconnect; boolean, reason?. |
Open77.players.kick |
players.disconnect |
(playerId, reason?) |
Alias of disconnect. |
Open77.players.ban |
players.ban |
(playerId, reason?, durationSeconds?) |
Persist a server-scoped device ban through the master and disconnect now. |
Open77.players.getLifeState |
players.life.read |
(playerId) |
Canonical life snapshot or nil. phase is alive, dead, revivepending, respawnpending, recovering β no underscore, unlike the client. |
Open77.players.isDead |
players.life.read |
(playerId) |
Whether phase is dead/revive-pending/respawn-pending. Resolved from the enum, so it is immune to the spelling split β prefer it over comparing phase. |
Open77.players.kill |
players.life.kill |
(playerId, options?) |
boolean, reason? |
Open77.players.revive |
players.life.revive |
(playerId, options?) |
boolean, reason? |
Open77.players.respawn |
players.life.respawn |
(playerId, options) |
boolean, reason?. Only for a player who is already dead β it refuses with invalid_state otherwise. To move a living player, use teleport. |
Open77.players.teleport |
players.teleport |
(playerId, position, options?) |
Promise, or nil, reason. Moves a living player with no life transition. |
Open77.players.setHeading |
players.teleport |
(playerId, yaw) |
boolean, reason?. Fire and forget. |
Open77.players.requestLifeResync |
players.life.resync |
(playerId) |
boolean, reason? |
Open77.players.setGhosted / SetPlayerGhosted |
players.life.ghost |
(playerId, ghosted, options?) |
Toggle the server-owned non-solid spawn state and its optional automatic clear policy. |
Open77.players.isGhosted / IsPlayerGhosted |
players.life.read |
(playerId) |
Whether the canonical life state is currently ghosted. |
Open77.players.setFrozen / SetPlayerFrozen |
players.life.freeze |
(playerId, frozen) |
Hold the player's body where it stands, or release this resource's hold. boolean, reason? |
Open77.players.isFrozen / IsPlayerFrozen |
players.life.read |
(playerId) |
Whether the canonical life state is currently frozen, by any resource. |
Open77.players.setVisible / SetPlayerVisible |
players.life.visibility |
(playerId, visible) |
Hide a player's body from every other client, or show it again. Presentation only; see Player visibility below. |
Open77.players.isVisible / IsPlayerVisible |
players.life.read |
(playerId) |
Whether the canonical life state says this player is rendered. Answered from the server bit, never probed from a client. |
Open77.players.setFallDamage |
players.life.falldamage |
(playerId, enabled) |
Switch one player's fall damage off (false) or back on. Fall damage only -- every other source of damage lands as before; this is not setGodMode. boolean, reason?. See Fall damage. |
Open77.players.isFallDamageEnabled |
players.life.read |
(playerId) |
Whether landings still hurt this player. Open77.players.get(playerId).fallDamage carries the same bit. |
Open77.players.ragdoll |
players.motion.control |
(playerId, { durationMs?, direction?, distance? }?) |
Put the body on the ground for up to 3 s; it gets up on its own. The engine's knockdown with a clock and a default facing; {ok=true,id=β¦,durationMs=β¦}, or nil, reason. See ragdoll and clearTasks. |
Open77.players.clearTasks |
any of players.motion.control, players.animations.control, players.life.freeze |
(playerId) |
End this resource's motion lease, animation playback and freeze claim on one player, reporting each half; another resource's holds are not_owned and untouched. { motion, animation, freeze, clear }, or nil, reason. |
Open77.players.spectate / SpectatePlayer |
players.spectate |
(playerId, targetId|false, options?) |
Put one player behind another player's shoulder: ghost, hide, and a follow camera on the target, as one transaction that fails closed. targetId false ends it and gives the body back, as do death, disconnect, reconnect, a bucket change and this resource stopping. options: blendMs, distance, height. See Spectating. |
Open77.players.spectating / GetPlayerSpectateTarget |
players.life.read |
(playerId) |
Who this player is watching, or 0. |
Open77.players.spectators / GetPlayerSpectators |
players.life.read |
(playerId) |
Everyone watching this player, ascending. |
Open77.players.fade / FadePlayerScreen |
players.screen |
(playerId, out, durationMs?, options?) |
Cover (out true) or uncover one player's screen with the native quest fade. The client consumes the relay itself, so the target needs no client resource; options.timeoutMs (1000-60000, default 15000) is a ceiling the client enforces on real time, so a forgotten fade-in costs a pause, not a black screen. See screen transitions. |
Open77.players.fadeOut |
players.screen |
(playerId, durationMs?, options?) |
fade(playerId, true, ...). |
Open77.players.fadeIn |
players.screen |
(playerId, durationMs?) |
fade(playerId, false, ...). |
Open77.players.getHealth |
players.damage.read |
(playerId) |
Health/armor/god-mode snapshot or nil. |
Open77.players.damage |
players.damage.apply |
(playerId, amount, options?) |
Apply authoritative damage. |
Open77.players.heal |
players.damage.apply |
(playerId, amount) |
Apply authoritative healing. |
Open77.players.setHealth |
players.damage.apply |
(playerId, health) |
Set health; zero passes through life authority. |
Open77.players.setMaxHealth |
players.damage.apply |
(playerId, maxHealth) |
Update the canonical maximum. |
Open77.players.setArmor |
players.damage.apply |
(playerId, armor) |
Update canonical armor. |
Open77.players.setGodMode |
players.damage.apply |
(playerId, enabled) |
Toggle canonical damage immunity. |
Open77.players.setRegen |
players.damage.apply |
(playerId, pointsPerSecond) |
Set authoritative regeneration. |
The rich read and the freshness decision
The commonest thing a server script does before it acts is a distance check, and
until get, nearby, closest and distance existed every gamemode wrote that
check again: a loop over Open77.players.position, a square root, and a
workaround for the two-second rule.
Open77.players.position is unchanged, deliberately. It still answers nil
once the player's last accepted snapshot is more than two seconds old, and that
silence is load-bearing: the server refuses to restamp a rejected or replayed
snapshot precisely so stale state cannot be laundered into freshness, and loot
pickup, damage context, interest reconciliation and voice routing all arbitrate
through the same accessor. Bundled resources already read that nil as "stale".
Changing it would have been a silent behaviour change in every one of them.
What was wrong was not the rule but the reporting. A script that only wants to
know how far away somebody is received nil for "this player vanished" and nil
for "I cannot date this reading", and had to guess. So the always-answering
behaviour lives on the new names:
local read = Open77.players.get(playerId) -- never silent on staleness
if read and read.position and (read.ageMs or 0) < 5000 then
-- your threshold, not ours
end
read.fresh is true exactly when Open77.players.position would return a position. A player without an accepted snapshot has neither position nor ageMs, which differs from a stale reading. Position freshness is not a readiness or liveness check: use the player's readiness gate before placement or gameplay actions.
Open77.players.get(playerId)
Always present: playerId, bucket, fresh, ready. Present when the player
has ever reported: ageMs, position ({ x, y, z }), heading and its alias
yaw, velocity ({ x, y, z }), speed, stateFlags, flags, and
supportKind/supportId when the player is standing on a moving support.
Present when the host can resolve them: name, identifier, userId.
flags names the snapshot's state bits: animationValid, sprinting,
crouched, sliding, vaulting, grounded, weaponValid, weaponEquipped,
combatValid, aiming, triggerDown, reloading. The three *Valid bits sit
beside the bits they qualify on purpose -- a client only fills a block when its
validity bit is set, so grounded == false under animationValid == false means
"not reported", not "airborne". stateFlags is the raw wire value, so a bit we
have not named is never a blocker.
Three groups are absent rather than false unless the calling resource already
holds the permission that guards them elsewhere. Surfacing them here without it
would be a way around the gate on getVehicleSeat, getHealth and
getLifeState:
| Fields | Permission | Note |
|---|---|---|
inVehicle, plus vehicleId/seat/entering/exiting/exitLocked when seated |
world.vehicles |
With the permission inVehicle is always present and may be false; without it the key is absent. inVehicle is the server's canonical seat reservation, which exists before the client has finished mounting. Authoritative, not snapshot-gated. |
health, maxHealth, armor, godMode |
players.stats.read (or legacy players.damage.read / players.life.read) |
Authoritative, not snapshot-gated. |
alive, dead, lifePhase, ghosted |
players.life.read |
lifePhase uses the server spelling -- alive, dead, revivepending, respawnpending, recovering, no underscore. Prefer dead over comparing the string. |
The read itself needs no permission. That is not an assumption about what
other runtimes do -- the client's life reads are gated, behind
players.life.read, and Open77.world's geometry reads are gated behind
world.query. It is the server's own standing policy for this family: a
server resource can already learn a player's id, name and position from
Open77.players.all, name and position, none of which asks for anything,
because a player's presence is not more sensitive than what those already expose.
get adds no new fact to that set without also adding its permission. Gating
the whole read instead would have made get strictly harder to use than the
position call it replaces, which only pushes resources back to the old one.
A rich read is not a state bag. Placement, motion and life are the server's observations of a player and belong here; a job, a duty flag, a shift or anything else a resource decides about a player belongs in that player's state bag, where it is named, replicated and owned by whoever set it. If you find yourself wanting a field here that no snapshot could ever carry, it is a bag field.
Not included, and why: anything the server would have to ask a client for. There
is no synchronous armour-piece list, weapon record, stance or animation name on
the server, and inventing one from the last snapshot would be a guess wearing a
field name. position, velocity and flags come from the snapshot and are
therefore dated by ageMs; everything in the permission table above is the
server's own record and is current.
nearby, closest, distance
local crowd = Open77.players.nearby(shopDoor, 25) -- a point
local witnesses = Open77.players.nearby(robberId, 40) -- or a player
local target = Open77.players.closest(playerId, { radius = 10 })
local metres = Open77.players.distance(cop, robber)
anchor is a player id (an integer) or a point (a table with numeric x, y,
z). The two are never ambiguous. A vector3 is a point -- the shared
vector type is a plain table with real x/y/z fields -- so vectors are
accepted everywhere a point is, and the { x, y, z } tables these reads return
go straight back into vector3().
nearby returns an array sorted nearest first, ties broken by player id so two
identical calls answer in the same order. Each entry carries playerId,
distance, bucket, fresh, and ageMs/name/position/heading/speed
where available. It is deliberately not the whole of get: filling the life,
damage and vehicle fields would mean asking three services once per player per
call for fields a proximity scan does not read. Call get on the one you picked.
An omitted or nil radius means no radius limit, which is how closest ranks
a whole bucket. options:
| Option | Default | Meaning |
|---|---|---|
bucket |
the anchor player's own bucket; every bucket for a point anchor | An integer narrows to that routing bucket. false means every bucket, and is how you opt back out when the anchor is a player. |
includeSelf |
false |
Whether a player anchor appears in its own result. Ignored for a point anchor, which excludes nobody. |
limit |
unlimited | Keep only the first n after sorting. |
maxAgeMs |
unlimited | Drop players whose last reading is older than this. The default keeps stale readings, because a player whose snapshots stalled is still somewhere and silence is not an answer; maxAgeMs is how you ask for a threshold of your own. |
radius |
-- | closest only, since it takes no positional radius. |
closest returns a bare nil -- no second value -- when the search succeeded and
nobody matched, and nil, reason when it failed. That is what separates an empty
street from a bad argument.
Failure tokens, stable across all four: invalid_player_id, invalid_argument,
invalid_position, invalid_radius, invalid_options, invalid_bucket,
invalid_limit, invalid_max_age, player_not_found, position_unknown
(the player exists but has never reported a transform) and sessions_unavailable
(an embedding that does not replicate).
Freezing a player
Open77.players.setFrozen(playerId, frozen) holds a player's body where it stands. It is the
primitive behind cuffs, a menu that must not be walked out of, a progress bar, a safe zone and a
scripted beat. The client applies the engine's own GameplayRestriction.NoMovement -- the same
record vanilla death applies -- so there is no animation fighting the hold, and the body does not
slide on any observer's screen: a frozen player keeps sending snapshots, their position simply
stops changing.
Be precise about what a freeze is, because the usability of the feature is exactly this list.
| Still works | Stopped |
|---|---|
| Looking around (mouse and controller camera) | Walking, running, sprinting, crouch-moving |
| Chat and voice | Jumping, dodging, sliding, vaulting |
| Opening menus and the inventory | Every other locomotion input, pressed or held |
| Interaction prompts | |
| Taking damage, and dying |
It does not holster a weapon, block firing, or eject anyone from a vehicle. A freeze is a primitive, not a policy: compose it with the control-blocking surface when a scene needs more. The precedent is the death restriction, which has spared chat and voice since it shipped.
The hold is a claim, not a value. It is keyed by the calling resource, so a jail script and a
cutscene script may hold the same player at once, and setFrozen(id, false) drops only your own
claim -- he stays frozen while anybody else still holds him. isFrozen answers for all of them.
Nothing can strand a player. A freeze is released by death, respawn, a bucket change,
disconnect, and by the resource that set it stopping, reloading or crashing. Death and respawn
clear it outright, so a gamemode that wants a player held through a respawn must re-apply it
afterwards. As with setGhosted, the call is refused with transition_in_progress while a revive
or respawn is in flight -- a resource stop during one still thaws, because a refusal there would
be exactly the stranding this guards against.
-- Cuffs: two resources may hold the same suspect; each releases only its own hold.
local ok, reason = Open77.players.setFrozen(playerId, true)
if not ok then return print("could not freeze: " .. tostring(reason)) end
SetTimeout(30000, function()
Open77.players.setFrozen(playerId, false)
-- Still true here if another resource has a hold of its own on him.
print("still held:", Open77.players.isFrozen(playerId))
end)
The flag rides the canonical life state, so it is reliable, it survives a proxy re-stream, and it
is readable as Open77.players.getLifeState(playerId).frozen. A client that predates the flag
decodes it and ignores it rather than disconnecting.
For a hold that needs no server round trip -- a local menu, a local progress bar -- the client has
Open77.character.setFrozen(frozen) under the player.freeze permission. It is
presentation-local, and the server's freeze outranks it: releasing a local hold while the server
holds the player changes nothing. Both halves, the engine lever behind them and every release path
are written up in Freezing a player.
Player visibility
Open77.players.setVisible(playerId, false) hides a player's body on every
other client. It is the presentation half of the pair whose other half is
setGhosted, and the two are deliberately independent: visibility and
collision are separate levers, so a player can be invisible and still solid
(still shootable, still blocking a door), or ghosted and perfectly visible. That
is what makes "invisible but still simulated" the default rather than a special
mode. Admin invisibility and spectating are the intended uses.
-- An admin drops out of sight but stays a real body in the world.
local ok, reason = Open77.players.setVisible(admin, false)
if not ok then print("setVisible refused: " .. reason) end
-- ...and back.
Open77.players.setVisible(admin, true)
Both calls return true, or false, reason β permission_denied:players.life.visibility,
player_not_found, invalid_argument, life_unavailable, or
transition_in_progress while a revive or respawn is mid-flight.
What it does and does not change.
| Invisible player | |
|---|---|
| Body rendered by other clients | no β the proxy's skinned meshes are hidden |
| Nameplate on other clients | no β withheld with the body, and a resource cannot opt out |
| Collision, aiming, being shot | unchanged β use setGhosted for that |
| Audible in proximity voice | yes, unchanged β see below |
Present in Open77.players.all() and the client roster |
yes, unchanged β see below |
| Map markers a resource draws itself | unchanged β the resource decides |
Proximity voice is untouched: an invisible player is still heard. Voice attenuation is computed from replicated positions and has no connection to this flag. Silencing him would be a second, unrelated policy β and a surprising one, since a spectating admin usually wants to be able to talk. Mute him explicitly through the voice API if that is what the gamemode wants.
He also still appears in every roster. Open77.players.all() on the server,
and the client-side player enumeration, both list him: this row changes
presentation, not membership. Hiding a player from a roster is a different
decision with different consequences (it would break scoreboards, chat targeting
and admin tooling), so a resource that wants him omitted filters on the flag
itself.
The answer comes from the server bit, and is never probed from the engine.
Cyberpunk 2.31 exposes no readable "is hidden" flag on entIVisualComponent
(the RTTI has autoHideDistance, renderSceneLayerMask and forceLODLevel, and
nothing else), so no client can be asked whether a body is actually hidden
right now. The life flag is the only truth: isVisible reads it, and each client
re-asserts the hide on a 1 Hz beat so a render-proxy rebuild β which is exactly
what dressing a garment performs β cannot quietly put the body back on screen.
Arbitration with the dressing phase. A remote body is also hidden while it is being clothed, through the same engine lever and the same per-entity ledger. The rule is: the body stays hidden while at least one owner holds it, and a release only shows the body when it is the last hold. So dressing an invisible player finishes without revealing him, and showing a player who is still being dressed does not reveal a half-dressed puppet β the body appears when both are done, in either order.
Nothing strands a player invisible. The flag is released on death, on respawn, on a routing-bucket change, on disconnect, on reconnect, and when the resource that set it stops or reloads. A release refused because a life transition is in flight is queued and retried, not dropped. This matters more than it does for ghosting, which auto-clears on separation: invisibility has no such fallback, and no client can be asked to recover one.
Unified health and stamina API
New resources should use players.stats.read and players.stats.apply. The
legacy players.damage.* permissions and Open77.players health methods remain
accepted aliases. The client exposes the same read names and the same
nested pool shape, but no setters. See Player health and stamina
for the full synchronization model.
| Function | Permission | Signature | Result / purpose |
|---|---|---|---|
Open77.stats.get / GetPlayerStats |
players.stats.read |
(playerId) |
Combined canonical health/stamina snapshot or nil. |
Open77.stats.getHealth / health |
players.stats.read |
(playerId) |
Health pool { value, current, maximum, max, fraction, percentage, regenEnabled, regenPerSecond, regenerating }. |
Open77.stats.getStamina / stamina |
players.stats.read |
(playerId) |
Stamina pool with the same shape. |
Open77.stats.set |
players.stats.apply |
(playerId, pool, value) |
Set health or stamina in points. |
Open77.stats.setMax |
players.stats.apply |
(playerId, pool, maximum) |
Set a canonical maximum and clamp the current value. |
Open77.stats.restore |
players.stats.apply |
(playerId, pool) |
Fill the selected pool. |
Open77.stats.setRegenEnabled |
players.stats.apply |
(playerId, pool, enabled) |
Toggle authoritative regeneration. |
Open77.stats.setRegenRate |
players.stats.apply |
(playerId, pool, pointsPerSecond) |
Set the rate; zero disables regeneration. |
Open77.stats.setHealth / SetPlayerHealth |
players.stats.apply |
(playerId, value) |
Explicit health setter; zero routes through life authority. |
Open77.stats.setHealthMax / SetPlayerMaxHealth |
players.stats.apply |
(playerId, maximum) |
Explicit health maximum setter. |
Open77.stats.restoreHealth / RestorePlayerHealth |
players.stats.apply |
(playerId) |
Fill health. |
Open77.stats.setHealthRegenEnabled / SetPlayerRegenEnabled |
players.stats.apply |
(playerId, enabled) |
Toggle health regeneration. |
Open77.stats.setHealthRegenRate / SetPlayerRegen |
players.stats.apply |
(playerId, rate) |
Set health regeneration rate. |
Open77.stats.setStamina / SetPlayerStamina |
players.stats.apply |
(playerId, value) |
Explicit stamina setter. |
Open77.stats.setStaminaMax / SetPlayerMaxStamina |
players.stats.apply |
(playerId, maximum) |
Explicit stamina maximum setter. |
Open77.stats.restoreStamina / RestorePlayerStamina |
players.stats.apply |
(playerId) |
Fill stamina. |
Open77.stats.setStaminaRegenEnabled / SetPlayerStaminaRegenEnabled |
players.stats.apply |
(playerId, enabled) |
Toggle stamina regeneration. |
Open77.stats.setStaminaRegenRate / SetPlayerStaminaRegen |
players.stats.apply |
(playerId, rate) |
Set stamina regeneration rate. |
The same setters are also reachable under Open77.players, for a resource that keeps every
player-facing call in one namespace. They are aliases of the Open77.stats entries above, not a
second implementation:
| Function | Permission | Signature | Alias of |
|---|---|---|---|
Open77.players.restoreHealth |
players.stats.apply |
(playerId) |
Open77.stats.restoreHealth |
Open77.players.setRegenEnabled |
players.stats.apply |
(playerId, enabled) |
Open77.stats.setHealthRegenEnabled |
Open77.players.setStamina |
players.stats.apply |
(playerId, value) |
Open77.stats.setStamina |
Open77.players.setMaxStamina |
players.stats.apply |
(playerId, maximum) |
Open77.stats.setStaminaMax |
Open77.players.restoreStamina |
players.stats.apply |
(playerId) |
Open77.stats.restoreStamina |
Open77.players.setStaminaRegen |
players.stats.apply |
(playerId, rate) |
Open77.stats.setStaminaRegenRate |
Open77.players.setStaminaRegenEnabled |
players.stats.apply |
(playerId, enabled) |
Open77.stats.setStaminaRegenEnabled |
Open77.players.setDownedDamageable(playerId, enabled) (low-level SetPlayerDownedDamageable)
requires players.damage.apply and decides whether a downed player can still be damaged: on for a
mode with finishing blows, off for one where a downed player is safe until revived.
Life option fields are:
kill:killer,cause,weapon,impulse = { x, y, z };revive:health,graceMs;respawn: requiredposition = { x, y, z }, plusheading,bucket,health,graceMs;damage:attacker,cause/kind,weapon.
Disconnecting and banning a player
Declare the capability explicitly in the server resource manifest:
permission "players.disconnect"
permission "players.ban"
Then disconnect an authenticated session ID with a reason shown to that client:
local ok, error = Open77.players.disconnect(playerId, "Banned: cheating")
if not ok then
print("disconnect failed", error)
end
-- FiveM-style low-level spelling and short namespaced alias:
DropPlayer(playerId, "Kicked by an administrator")
Open77.players.kick(playerId, "Server maintenance")
-- Permanent when durationSeconds is omitted; otherwise temporary.
local banned, banError = Open77.players.ban(playerId, "Cheating", 86400)
-- FiveM-style low-level spelling:
BanPlayer(playerId, "Cheating", 86400)
The reason is trimmed, must contain no control characters, and is limited to 127 UTF-8 bytes so
the GNS transport can deliver it without truncation. Omitting it uses Disconnected by server..
The closure is deferred until the current server Lua callback has returned; repeated calls for the
same player in one tick are idempotent and the first reason wins.
Open77.players.ban and BanPlayer resolve the player's authenticated device identity, submit a
server-scoped ban to the master, and queue the immediate disconnect. The optional duration is a
strictly positive number of seconds; omitting it creates a permanent ban. Ban reasons accept up to
512 UTF-8 bytes. Central publication is best-effort and asynchronous, while the local kick is
queued immediately.
Normal failures return false, reason, where reason is one of
permission_denied:players.disconnect, invalid_player_id, invalid_reason,
player_not_found, or server_unavailable.
Ban failures return permission_denied:players.ban, invalid_player_id, invalid_reason,
invalid_duration, server_unavailable, or ban_failed.
Low-level aliases are GetPlayerName, GetPlayerIdentifier, GetPlayerPosition, GetPlayers,
GetPlayersInBucket, GetPlayersPositions, DropPlayer, BanPlayer, GetPlayerLifeState, IsPlayerDead, KillPlayer, RevivePlayer, RespawnPlayer,
RequestPlayerLifeResync, GetPlayerHealth, DamagePlayer, HealPlayer, SetPlayerHealth,
SetPlayerMaxHealth, SetPlayerArmor, SetPlayerGodMode, SetPlayerRegen, GetPlayerRead,
GetPlayersNearby, GetPlayerDistance, and GetPlayerHoloCallEyes (the read half of
holocall eyes, the same function as Open77.players.getHoloCallEyes). Prefer the namespaced wrappers because they accept
structured option tables.
Vehicle-seat low-level aliases are SetPlayerIntoVehicle, ForcePlayerOutOfVehicle,
SetPlayerVehicleExitLocked, and GetPlayerVehicleSeat; their full contract is documented under
Vehicles.
Moving a player
Open77.players.teleport(playerId, position, options?) is the placement primitive.
It moves a living player with no life transition: health, inventory, weapons and
vehicle occupancy are untouched. It returns a promise that resolves when the client
reports the body settled at the destination. (Clients 65 to 67 moved the body but
never answered: their settle watch was never ticked, so every promise ended in
settle_timeout about fifteen seconds later. Client 68 answers -- settled or
near in well under two seconds on a loaded floor.)
CreateThread(function()
local placed, reason = Open77.players.teleport(source, { x = 1669.75, y = -739.12, z = 49.86 }, {
heading = 180,
bucket = 4, -- changed before the move, so nobody in the old bucket sees the new place
fade = true, -- default; fadeOutMs / fadeInMs default to 400
}):await()
if not placed then
print("teleport refused: " .. tostring(reason))
return
end
print(("landed %s at %.2f %.2f %.2f"):format(placed.state, placed.x, placed.y, placed.z))
end)
Resolves with { x, y, z, state }. state is settled when the body was grounded on
the point for three consecutive frames, or near when the window closed with the body on
the point but never reported grounded β a mark on a prop, or a very slow stream-in. Both
are successes; near is the weaker claim.
Rejects, all stable snake_case:
| Reason | Meaning |
|---|---|
player_not_ready |
The player has no life record or the readiness gate is closed. Placement is refused to prevent a client crash before the body is ready. |
player_not_alive |
The life phase is not alive. A move is not a resurrection; use revive or respawn. |
player_in_vehicle |
The body is mounted. See below. |
dismount_failed |
dismount = true was asked for and the body was still in its seat when the ejection budget closed. Refused rather than teleporting a mounted body. |
invalid_position |
Not three finite numbers, or outside the world by orders of magnitude. |
settle_timeout |
The client never got the body to stand at the destination inside the budget, or never answered at all. |
settle_superseded |
A second teleport for the same player started before this one finished. There is one body, so there is one placement. |
Why not respawn
Open77.players.respawn cannot move a living player. PlayerLifeService.Respawn
refuses unless the phase is already Dead, which is why the only sanctioned placement in
the platform before this one was a kill β respawn: a whole life transaction β a death, a
ragdoll, a resurrect, a health write and a grace window β to move a body four metres.
What that transaction carried and a bare transform write does not is the part that
matters, and teleport keeps it: the client watches the body and only then reports it
placed. A direct write over any real distance drops the player through a floor that has
not streamed in, the engine's own fall-under-world failsafe returns them to the save's
spawn kilometres away, and nothing notices. The client re-issues the teleport every
250 ms while the body sinks, which resets the fall before the failsafe can fire and keeps
the streaming prefetch aimed at the mark.
A player in a vehicle
Refused with player_in_vehicle, by default and on purpose.
gameTeleportationFacility::Teleport moves the player object; it has no working vehicle
variant (for a non-player entity the call reports success and the entity never moves), so
there is no way to carry the car along. Moving the occupant alone leaves the seat behind,
and the platform's older kill β respawn primitive dealt with a mounted player by
unmounting first β that is, by losing the vehicle.
Choose whether to reject mounted players or eject them before placement:
Open77.players.teleport(source, point, { dismount = true })
With dismount, the client stops the seat workspot and unmounts the occupant behind the
fade, retrying at 100 ms for up to 750 ms β the same cadence and budget the death
transaction pays for the same transition β and refuses with dismount_failed if the body
is still seated when that closes. It never teleports a mounted body.
Silently ejecting by default would destroy the vehicle state this API promises to keep.
setHeading
Open77.players.setHeading(playerId, yaw) turns a living player to face a yaw. It is the
cheap half: a heading streams nothing and cannot fall through a floor, so it dispatches
and returns true rather than confirming. It refuses with the same player_not_ready /
player_not_alive rules, plus invalid_heading.
Combat policy
The global combat policy functions below require combat.config. Scoped PvP
grants use the separate combat.scope.control permission.
| Function | Signature | Purpose |
|---|---|---|
Open77.combat.onDamage |
(handler) |
Add a synchronous damage arbiter and return it. false cancels; a number rewrites damage. |
Open77.combat.offDamage |
(handler) |
Remove a previously installed arbiter. |
Open77.combat.setFriendlyFire |
(enabled) |
Enable or disable global player-versus-player damage; equal nonzero teams remain protected. |
Open77.combat.setTeam |
(playerId, teamId) |
Assign a non-negative team. |
Open77.combat.setDamageMultiplier |
(multiplier) |
Set global multiplier, range 0β100. |
Open77.combat.setHeadshotMultiplier |
(multiplier) |
Set headshot multiplier, range 0β100. |
Open77.combat.setWeaponDamageMultiplier |
(weaponTdbId, multiplier) |
Override one weapon, range 0β100. |
Open77.combat.setKindDamageMultiplier |
`(ranged | melee |
Low-level aliases are SetCombatFriendlyFire, SetCombatTeam, SetCombatDamageMultiplier,
SetCombatHeadshotMultiplier, SetCombatWeaponMultiplier, and SetCombatKindMultiplier.
Scoped PvP
Open77.combat.createScope({bucket=7, players={firstPlayer, secondPlayer}}) returns
a scope ID string, or nil, reason. Open77.combat.removeScope(id) returns
true, or nil, reason. Both require combat.scope.control; combat.config
alone does not grant them. Scopes belong to the calling resource instance.
A scope permits its explicit roster to damage one another in that bucket while
global PvP remains disabled. It changes no global settings and never overrides
same-team, god-mode, life, range or Lua damage-veto checks. It does not disable
damage that an existing global PvP policy already permits. The resource still
owns admission/consent and may use onDamage for additional arena rules.
Creation requires 2β32 distinct living registered players already in the specified bucket. A player can belong to only one scope. Limits are 64 scopes globally and eight per resource instance. Resource stop/disposal releases its scopes. Disconnect, bucket exit and body registration remove that participant; fewer than two remaining participants destroys the scope. Reconnecting or returning to the bucket does not restore admission. Remove and recreate a scope to change its roster; an ID from an earlier resource instance cannot be reused.
Typical refusals: invalid_scope, player_unavailable, player_scoped,
scope_limit, scope_owned, scope_unavailable, resource_stopping.
This API supplies combat permission only; it does not teleport players, switch
teams, alter implants or provide a temporary equipment loadout.
Routing buckets
| Function | Signature | Result / purpose |
|---|---|---|
Open77.routingBuckets.getPlayer |
(playerId) |
Player bucket, default 0. |
Open77.routingBuckets.setPlayer |
(playerId, bucket) |
boolean; moves authoritative visibility scope. |
Open77.routingBuckets.getEntity |
(entityId) |
Entity bucket, default 0. |
Open77.routingBuckets.setEntity |
(entityId, bucket) |
boolean. |
Open77.routingBuckets.setLockdownMode |
(bucket, mode) |
Mode: inactive, relaxed, strict, or full. |
Open77.routingBuckets.setPopulationEnabled |
(bucket, enabled) |
Toggle ambient population policy for the bucket. |
The corresponding globals are GetPlayerRoutingBucket, SetPlayerRoutingBucket,
GetEntityRoutingBucket, SetEntityRoutingBucket, SetRoutingBucketEntityLockdownMode, and
SetRoutingBucketPopulationEnabled.
Scope events
What "scope" means here. Player B is in player A's scope when A's client currently holds B
in its roster β when the server has sent A a PlayerJoined for B and no PlayerLeft since. That
set is A's routing bucket, narrowed to the player interest radius when the server has one:
simulation.playerInterestRadius in server.jsonc, in metres, with
simulation.playerInterestHysteresis (default 60 m) on the way out. The radius is off by
default (0), and every mode before Cordon runs that way; then scope is simply the whole
bucket and the events below fire on joins, bucket transfers and disconnects only. Cordon runs a
500 m radius β never below the longest ranged damage the server honours β and with a radius the
same events also fire when two players walk apart or back together.
Nothing is measured for these events: the server raises exactly one edge per roster message it
already sends, so the feed is balanced β every entered has a matching left, from the join
to the disconnect β and a resource that keeps a per-viewer set from it is never left holding a
player whose client has already been told to forget them.
| Event | Signature | Emitted |
|---|---|---|
onPlayerEnteredScope |
(playerId, forPlayerId, reason) |
playerId entered the scope of forPlayerId. reason is joined (a hello: the joiner is handed the whole bucket and the bucket is handed the joiner, so it fires in both directions for every pair), bucket (a routing-bucket transfer, both directions for every new peer) or interest (the radius, only when it is on). |
onPlayerLeftScope |
(playerId, forPlayerId, reason) |
playerId left the scope of forPlayerId. reason is disconnected (one edge per viewer that still has a client; the leaver's own view is not reported), bucket or interest. |
playerEnteredScope / playerLeftScope |
(data) with data.player, data["for"] |
The same edges in FiveM's shape: one table, both ids as strings, exactly what a ported script indexes. |
All ids are strings, as every host event's arguments are. The edges are queued by the host
and delivered on its next tick (a bucket edge can be raised from inside a Lua
SetPlayerRoutingBucket, and nothing is ever delivered from the caller's stack), so within a
tick no order is promised between the two spellings, nor between a join's scope edges and
onPlayerConnected / onPlayerDisconnected. A joined edge concerns a player already in the
registry; a disconnected edge concerns one already gone from Open77.players.get, so a
handler must not look them up. All four names are reserved and cannot be published by a
resource.
-- Proximity chat that only relays to players who can actually see the speaker.
local canSee = {} -- canSee[viewer][player] = true
AddEventHandler("onPlayerEnteredScope", function(player, viewer, reason)
canSee[viewer] = canSee[viewer] or {}
canSee[viewer][player] = true
end)
AddEventHandler("onPlayerLeftScope", function(player, viewer, reason)
if canSee[viewer] then canSee[viewer][player] = nil end
if reason == "disconnected" then canSee[player] = nil end -- the leaver's own view is gone
end)
RegisterNetEvent("chat:local", function(text)
local speaker = tostring(source)
for viewer, seen in pairs(canSee) do
if seen[speaker] then TriggerClientEvent("chat:addMessage", tonumber(viewer), { text = text }) end
end
end)
A ported FiveM resource needs no change:
AddEventHandler("playerEnteredScope", function(data)
print(("%s entered the scope of %s"):format(data.player, data["for"]))
end)
Time and weather
Every call requires world.environment β the same capability name the client side of this API
uses. The full guide, including the wire protocol, the presets and the operator commands, is
Synchronised time and weather.
| Function | Signature | Result / purpose |
|---|---|---|
Open77.environment.setTime |
(hour, minute, second, bucket?) |
Sets the authoritative clock. State table, or nil, reason. |
Open77.environment.setTimeFrozen |
(frozen, bucket?) |
Stops or resumes the clock at its current reading. |
Open77.environment.setTimeRate |
(rate, bucket?) |
Game seconds per real second, 0β120. |
Open77.environment.setWeather |
(preset, transitionSeconds?, bucket?) |
Applies a preset over a transition of 0β300 s. |
Open77.environment.setWeatherFrozen |
(frozen, bucket?) |
Pins the preset by stopping the weighted random scheduler. |
Open77.environment.getState |
(bucket?) |
The canonical state, including both frozen flags. |
Open77.environment.clearBucket |
(bucket) |
Retires a per-bucket override; its players return to the default sky. |
Open77.environment.publishChange |
(state) |
The authority's own door onto onEnvironmentChanged. No other resource needs it. |
-- Night, frozen, and a clear sky that stays clear.
local state, reason = Open77.environment.setTime(23, 0, 0)
if not state then return print("no environment authority: " .. reason) end
Open77.environment.setTimeFrozen(true)
Open77.environment.setWeather("sunny", 10)
Open77.environment.setWeatherFrozen(true)
getState() answers the same table every setter returns:
| Field | Meaning |
|---|---|
scope, bucket |
default and nil, or bucket:<n> and <n> for an override. |
hour, minute, second, secondsOfDay |
The authoritative clock at the moment of the call. |
rate, frozen / timeFrozen |
Game seconds per real second, and whether the clock is stopped. |
weather, weatherPreset, weatherPriority |
Open77 name, REDengine 24h_weather_* value, submission priority. |
transitionSeconds, weatherTransitionRemainingMs |
The transition requested, and what is left of it. |
weatherFrozen, randomWeather, nextWeatherInMs |
Whether the weighted scheduler is pinned, and its deadline. |
revision, weatherRevision, authorityEpoch |
The versioning clients use to reject a stale snapshot. |
buckets |
Every routing bucket currently holding an override, ascending. |
Failure tokens: permission_denied:world.environment, environment_unavailable, invalid_time,
unknown_weather, invalid_transition, transition_must_be_between_0_and_300,
rate_must_be_between_0_and_120, invalid_bucket, unknown_bucket,
too_many_environment_overrides, invalid_argument.
Per-bucket overrides
A routing bucket is a separate world, so a race at night and a freeroam at noon is one server.
Passing bucket to any setter creates that bucket's override, seeded from the default environment
as it reads at that moment, and updates it afterwards. clearBucket retires it.
Open77.environment.setTime(23, 0, 0, 5) -- bucket 5 only
Open77.environment.setWeather("rain", 0, 5)
Open77.environment.clearBucket(5) -- back to the default sky
Two consequences worth knowing before reaching for it. A player who changes bucket is re-synced individually, so the move is seamless; and while any override exists, the authority stops broadcasting and addresses every connected player individually, because a broadcast would reach the overridden bucket too and overwrite it. A server that never creates one pays nothing.
onEnvironmentChanged
A platform event: it reaches every running resource, needs no capability to receive, and cannot be
published by a resource β TriggerEvent("onEnvironmentChanged", β¦) answers false, "reserved_event". The payload is exactly what getState() returns, plus reason, so a HUD can
render from it without asking a second question.
AddEventHandler("onEnvironmentChanged", function(state)
print(("%s at %02d:%02d (%s)"):format(state.weather, state.hour, state.minute, state.reason))
end)
It fires on a real change only β never on the 5-second heartbeat and never on a client's sync request.
Ground loot
Every method requires world.loot. Drops are authoritative and owned by their creating resource.
| Function | Signature | Result |
|---|---|---|
Open77.loot.create |
(definition) |
Open77 loot ID, or nil, reason. |
Open77.loot.update |
(id, patch) |
boolean |
Open77.loot.remove |
(id) |
boolean |
Open77.loot.get |
(id) |
Drop snapshot or nil. |
Open77.loot.all |
(bucket?) |
Array of drop snapshots. |
Definitions accept item, quantity, position, bucket, radius, label, visualItem/model,
and ttlMs. Low-level aliases are CreateLootDrop, UpdateLootDrop, RemoveLootDrop,
GetLootDrop, and GetLootDrops. See loot for pickup validation and client projection.
Vehicles
Every method in this section requires world.vehicles. IDs are server-assigned Open77 IDs; do not
substitute REDengine entity pointers or local spawn handles.
The vehicle guide lists the complete server and client surfaces, the package exports, exact snapshot fields, bit indexes, and examples.
Lifecycle and state
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.create |
(definition) |
Create and return an authoritative vehicle ID. |
Open77.vehicles.update |
(id, patch) |
Patch health, flags, colours, openings and all durable damage channels. |
Open77.vehicles.get |
(id) |
Full canonical vehicle snapshot or nil. |
Open77.vehicles.all |
(bucket?) |
All canonical vehicles, optionally filtered by bucket. |
Open77.vehicles.setTransform |
(id, transform) |
Set authoritative position and yaw. |
Open77.vehicles.remove |
(id) |
Remove the authoritative vehicle. |
Open77.vehicles.getDamage |
(id) |
{ body, glass, lights, tires, detachedParts } or nil. |
Open77.vehicles.setDamage |
(id, damage) |
Replace combined damage fields. |
Open77.vehicles.repair |
(id, scope?) |
Scope: glass, body, lights, tires, visual, mechanical, or full. |
Open77.vehicles.setPaint |
(id, paint) |
Set authoritative primary/secondary RGB paint. |
Open77.vehicles.getPaint |
(id) |
{ applied, primary, secondary } or nil. |
Open77.vehicles.resetPaint |
(id) |
Restore the vehicle's original paint. |
Open77.vehicles.setLocked |
(id, locked) |
Atomically set or clear the durable entry lock. |
Open77.vehicles.lock / unlock |
(id) |
Entry-lock convenience methods. |
Open77.vehicles.isLocked |
(id) |
Canonical entry-lock boolean or nil. |
Open77.vehicles.triggerHorn / honk |
(id, durationMs?) |
Reliably sound the horn through the owner or every parked-vehicle viewer. |
Open77.vehicles.setHealth / getHealth |
(id, health) / (id) |
The one normalized 0..1 health pool. |
Open77.vehicles.setEngineHealth / getEngineHealth |
(id, health) / (id) |
The same pool under its FiveM name. |
Engine, lights and siren
These write one named bit of the durable flags and leave the rest alone. flags and update
are unchanged and keep working exactly as they did; these are another way into the same canonical
field, not a replacement for it.
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.setEngine |
(id, on) |
Start or stop the engine. |
Open77.vehicles.startEngine / stopEngine |
(id) |
Aliases that read as actions. |
Open77.vehicles.isEngineOn |
(id) |
Canonical engine boolean, or nil, reason. |
Open77.vehicles.setDrivable |
(id, drivable) |
Take the car out of service, or put it back. |
Open77.vehicles.setUndriveable |
(id, undriveable) |
Inverted spelling, for a SetVehicleUndriveable port. |
Open77.vehicles.isDrivable |
(id) |
Canonical in-service boolean, or nil, reason. |
Open77.vehicles.setLights |
(id, mode) |
"off", "on", "high", or a boolean. |
Open77.vehicles.setHighBeams |
(id, on) |
Raise or drop the beams without going dark. |
Open77.vehicles.getLights / areLightsOn |
(id) |
The mode as a word, or whether anything is lit. |
Open77.vehicles.setSiren |
(id, on) |
Light bar and sound together. |
Open77.vehicles.isSirenOn |
(id) |
Canonical siren boolean, or nil, reason. |
Each electrical write also records a ten-second intent that outranks the physics owner's durable report for the bits it names, and is released the moment that owner reports the same value. Without it a setter races the 500 ms owner report that was already in flight and loses at random -- the mechanism that made Pursuit's cop sirens go dark five seconds after every spawn.
setDrivable(id, false) cuts the engine in the same canonical revision and locks the simulated
driver's throttle and brake on every projection. Putting the car back in service does not restart
the engine; that is setEngine. Like the entry lock, the bit is server-authored and no owner
report can clear it.
The siren's light bar and sound cannot be separated: the only native this build exposes is
vehicleBaseObject.ToggleSiren(Bool), with no channel argument. Indicators and the interior
light are not supported by this API. An authority release or revoke switches
every electrical bit off by platform design, so re-assert on onVehicleAuthorityChanged if a
parked car must stay lit. Full reasoning and the worked examples are in the
vehicle guide.
Per-vehicle gameplay data a server invents -- fuel, keys, a plate, an owner -- does not belong in these flags. Put it in a state bag on the vehicle instead.
Transform and motion
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.getPosition |
(id) |
{ x, y, z }, which is a vector3. |
Open77.vehicles.getHeading / getYaw |
(id) |
Heading in degrees, the same units as the player read. |
Open77.vehicles.getSpeed |
(id) |
Metres per second. |
Open77.vehicles.getSpeedKph |
(id) |
Kilometres per hour, the unit speed limits use. |
Open77.vehicles.getVelocity |
(id) |
{ x, y, z } in metres per second. |
Open77.vehicles.getDriver |
(id) |
The driving player id, or nil. |
Open77.vehicles.get carries all of it, so these are shortcuts rather than extra work:
position, heading and yaw (one value, both names), orientation as { x, y, z, w },
velocity, angularVelocity, speed, speedKph, onGround, reversing, moving,
driverPlayerId, and the derived exploded / destroyed booleans. x, y and z are
unchanged and still there.
Motion is the last thing the physics owner reported. A vehicle with no owner is not being
simulated by anyone, so losing the lease -- a driver stepping out, disconnecting, or the lease
expiring -- zeroes the velocity, the speed and the dynamics bits rather than leaving a parked car
claiming the speed it had when its driver got out. A server setTransform clears them for the same
reason. physicsOwner == 0 and moving == false both say so, and moving is exactly
physicsOwner ~= 0 and speed > 0.1.
driverPlayerId is absent, not zero, when nobody is driving -- and absent while an entry animation
is still running, because a reserved seat is not yet a driver. heading is extracted from the full
quaternion rather than from qz/qw alone, so a car on a slope reports the heading it is actually
facing.
Only server-spawned vehicles are in the registry, so none of this answers for a vanilla traffic car a player climbed into. Adopting vanilla traffic is a separate capability that does not exist yet.
Performance ceilings
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.setPerformance |
(id, profile) |
Cap top speed and pickup, or clear with nil. |
Open77.vehicles.clearPerformance |
(id) |
Hand the vehicle back its rated performance. |
Open77.vehicles.getPerformance |
(id) |
The ceiling in force, or nil. |
Open77.vehicles.setPerformance(id, { topSpeedKph = 60.0, accelerationScale = 0.5, taperKph = 10.0 })
| Field | Default | Meaning |
|---|---|---|
topSpeedKph |
0 |
Speed ceiling in km/h. Zero means no ceiling. |
accelerationScale |
1 |
Throttle authority at every speed, in (0, 1]. This is how a car is made to pick up more slowly rather than only to top out lower. |
taperKph |
12 |
How many km/h below the ceiling the throttle starts easing off, so the car settles into its limit instead of slamming into it. Must be 3..200. |
A profile that asks for nothing -- no ceiling and full throttle authority -- is stored as no
profile at all, which is the same thing clearPerformance does. The taper is deliberately not part
of that test: a roll-off width around a ceiling that does not exist changes nothing.
The taper is never zero. Below about 3 km/h the roll-off is short enough that the limiter hunts
around the ceiling, which reads as stutter, and the client-local governor has refused that band
since it shipped. The server form enforces the same envelope -- topSpeedKph 0..1000,
accelerationScale in (0, 1], taperKph 3..200 -- on the Lua boundary, in the registry and on
both sides of the wire, so a server cap cannot be a way around a rule that exists because somebody
measured it. Refusals are invalid_top_speed, invalid_acceleration, invalid_taper and
vehicle_not_found.
This is the server-authoritative form of the client-local governor. The client-side
Open77.vehicles.setPerformance (permission vehicles.performance) is unchanged and still works;
it takes the same three numbers, and the difference is only who decides them. A tuning shop, or a
speed limit inside an instance, cannot be a decision the client makes.
The ceiling is replicated to every viewer, not only to the client that currently owns the physics. Ownership changes every time a driver steps in or out, and a ceiling chased across those transitions would be missing for exactly the first second of every drive -- the second a speed limit has to hold. Each client applies it to its own projection; the native governor only has throttle to clamp on the machine actually simulating the car, so sending it wide costs a no-op on observers.
It also survives the owner's durable report, by construction rather than by a window: that report carries no performance field, so the merge has nothing to overwrite. The one race that does exist is on the client, where the ceiling routinely arrives before the projection has attached -- a server-created vehicle has been measured taking 3.7 s to stream in -- so the client holds the intent on the replica and applies it when the entity appears, and again if the projection re-attaches.
Read the ceiling back on the client from the vehicle snapshot's own drivetrain telemetry
(speed, speedKph, rpm); the server read carries it as get(id).performance.
Explode, and locking for one player
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.explode |
(id) |
Detonate the vehicle. |
Open77.vehicles.setLockedForPlayer |
(id, playerId, locked) |
Decide one player's lock; nil clears the exception. |
Open77.vehicles.clearLockedForPlayer |
(id, playerId) |
Drop one player's exception. |
Open77.vehicles.setLockedForAll |
(id, locked) |
Set the canonical lock and revoke every exception. |
Open77.vehicles.lockForAll / unlockForAll |
(id) |
Aliases that read as actions. |
Open77.vehicles.isLockedForPlayer |
(id, playerId) |
What that one player sees, or nil, reason. |
What explode does. It writes the canonical ledger: health to zero, destroyed and exploded
raised, one revision. Every client that has the vehicle streamed then reconciles its own projection
through the damage path that already replays a wreck to a late joiner, which ends in the engine's
own explosion on that machine. The blast, the sound and the physics are REDengine's, on every
viewer at once.
- Occupants stay occupants. Nobody is ejected, no seat is freed, no life state changes, and the server sends no damage to any player. What the blast does to a body -- inside the car or standing next to it -- is the engine's own damage model running on each client, and it reaches the server the ordinary way, through the player damage report. A resource that wants the occupants dead has to say so itself.
- It is terminal. A second call on an already exploded vehicle is refused with
already_explodedrather than re-detonating a wreck. A vehicle that is merelydestroyed-- written off by collisions, never blown up -- is still a legitimate target. - An owner report cannot undo it. Health merges by minimum and the destruction bits by OR, both monotone, so a report taken a moment before the explosion and claiming a pristine car does not bring it back to life.
Per-player locks exist because all-or-nothing is the wrong shape for keys. A keys resource locks the car for the street and excepts the holders:
Open77.vehicles.setLockedForAll(id, true) -- the street
Open77.vehicles.setLockedForPlayer(id, holder, false) -- the key holder
The canonical bit stays locked, so a late joiner is told the right thing with no bookkeeping, and
a key holder who disconnects takes their exception with them. The substitution happens at the point
where state reaches a socket, so no fan-out path can disagree with isLockedForPlayer.
setLockedForAll revokes every exception as well as moving the canonical bit -- "lock this for
everyone" has to mean everyone, including the holders excepted a minute ago. Open77.vehicles.setLocked
is unchanged and moves only the canonical bit, leaving exceptions standing.
Lifetime
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.setTimeToLive / setTtl |
(id, ttlMs) |
Give the vehicle a deadline; 0 or nil cancels one. |
Open77.vehicles.setPersistent |
(id, persistent) |
Exempt it from every automatic removal. |
Open77.vehicles.setDespawnWhenUnobserved |
(id, despawn) |
Remove it once nobody has it streamed. |
Open77.vehicles.create takes the same three as ttlMs, persistent and despawnWhenUnobserved,
so a garage does not have to make two calls. A definition without them behaves exactly as it did
before. A lifetime that cannot be applied undoes the creation rather than half-honouring it, the
same rule the initial-damage path already follows.
persistent has one meaning, deliberately: nothing automatic removes this vehicle. No
time-to-live takes it, the unobserved sweep does not take it, and it survives the stop of the
resource that created it. An explicit Open77.vehicles.remove still takes it -- persistence is
protection from the reapers, not from the owner.
despawnWhenUnobserved removes the vehicle once no player has had it streamed for thirty
seconds. The window is not zero because interest is recomputed against the last received player
snapshot, and a loading screen or a bucket change briefly empties a viewer set that is about to
refill. The clock runs for a vehicle nobody has ever seen too, which is what a garage wants: a
car spawned for a player who never turns up still goes. A viewer coming back resets the clock
rather than pausing it.
A deadline is read back as get(id).ttlMs, counted from now, and is absent rather than zero once
it is cancelled. ttlMs is capped at seven days, mirroring the prop registry; invalid_ttl is the
refusal.
A deadline takes an occupied vehicle too, deliberately -- a time-to-live silently declined
because someone was sitting in the car is a worse surprise than the removal. The seat ledger is
closed out properly either way. Clear the deadline on onPlayerEnteredVehicle, or mark the vehicle
persistent, if that matters. The unobserved sweep cannot hit an occupied vehicle at all: a seated
player always has their vehicle streamed.
Finding a vehicle
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.nearby |
(anchor, radius?, options?) |
Registered vehicles near a point or a player, nearest first. |
Open77.vehicles.closest |
(anchor, options?) |
The nearest one, or a bare nil. |
Open77.vehicles.seatFree |
(id, seat) |
Whether one seat is free. |
Open77.vehicles.occupantInSeat |
(id, seat) |
The player in one seat, or nil. |
Open77.vehicles.freeSeats |
(id) |
Free seats in canonical order. |
Open77.vehicles.seatName |
(seat) |
Canonical spelling of any accepted seat form. |
The anchor, bucket and limit conventions are exactly those of Open77.players.nearby: a position
table (a vector3 is one) or a player id, a player anchor defaulting to that player's own routing
bucket, bucket = false for every bucket, ties broken by id. options.occupied keeps only
occupied (true) or only empty (false) vehicles, and limit caps the list. Nothing in range is a
bare nil with no reason.
Only server-spawned vehicles are in the registry. A player standing next to a vanilla traffic
car is standing next to something REDengine population spawned on their own client: it has no
canonical id and none of these calls can see it. closest will answer with a registered car
further away instead, which is expected rather than broken -- check distance yourself if it
matters. Adopting vanilla traffic is a separate capability that does not exist yet.
Player seats
| Function | Signature | Purpose |
|---|---|---|
Open77.vehicles.warpPlayerIntoVehicle |
(playerId, vehicleId, seat, options?) |
Reserve a canonical vehicle seat and durably force the client mount, including late stream-in. |
Open77.vehicles.setPlayerIntoVehicle |
(playerId, vehicleId, seat, options?) |
Alias of warpPlayerIntoVehicle. |
Open77.vehicles.forcePlayerOutOfVehicle |
(playerId, vehicleId?) |
Force native unmount, overriding exit lock. |
Open77.vehicles.removePlayerFromVehicle |
(playerId, vehicleId?) |
Alias of forcePlayerOutOfVehicle. |
Open77.vehicles.setPlayerExitLocked |
(playerId, locked, vehicleId?) |
Set or clear the durable no-exit policy. |
Open77.vehicles.getPlayerSeat |
(playerId) |
Canonical assignment or nil. |
Open77.vehicles.isPlayerExitLocked |
(playerId) |
Canonical exit-lock boolean. |
Seat changes also raise three derived events -- onPlayerEnteringVehicle,
onPlayerEnteredVehicle and onPlayerLeftVehicle, each (playerId, vehicleId, seat) -- so a
resource no longer has to fetch the vehicle and diff the occupant list itself on every
onVehicleOccupancyChanged. They are derived from that same ledger at the single point it is
published, entered/left are balanced pairs, and the names are reserved: a resource publishing
one gets false, "reserved_event". The exact firing rules are in the
vehicle guide.
options.moveBucket defaults to true; when false, a bucket mismatch returns wrong_bucket.
options.exitLocked (alias lockExit) defaults to false. Seats accept FiveM indexes -1..2,
the Open77.vehicles.seats constants, aliases such as driver/frontPassenger, and canonical
seat_* names. Seat coordination is cross-resource: world.vehicles permits assignment, ejection,
and exit locking in any canonical vehicle. A forced entry bypasses proximity and the vehicle locked
flag but rejects destroyed/exploded targets and occupied seats. Durable vehicle state and lifecycle
mutations follow the same cross-resource contract.
The forced order remains in reliable occupancy state until native confirmation, so it survives a target that has not streamed yet, a bucket transition, stream-out/stream-in, and local projection replacement. Exit lock blocks ordinary unmount and manual seat switching; a forced exit overrides it and retains the seat until confirmation or the bounded transition timeout.
Player-oriented aliases are Open77.players.getVehicleSeat, warpIntoVehicle,
forceOutOfVehicle, and setVehicleExitLocked. See
authoritative player seats for assignment fields,
failure reasons, client read APIs, and examples.
create accepts record, position, yaw, bucket, appearance, health, flags,
primaryColor, secondaryColor, the nested paint = { primary, secondary } form, and the same
initial damage/opening fields accepted by update. Paint colors accept "#RRGGBB", { r, g, b },
or positional { r, g, b } tables. Supplying only a primary color mirrors it to secondary.
setPaint and resetPaint publish reliable canonical state to current viewers and late joiners;
pure black is valid because paintApplied is a separate flag. See the dedicated
vehicle paint guide for complete examples, events, and native limitations.
setLocked, lock, and unlock change only the locked flag and publish durable state to current
viewers and late joiners. The entry lock blocks ordinary seat claims; it is distinct from the
per-occupant setPlayerExitLocked policy and from client-side grid freeze/control locks. Forced
server assignment may deliberately bypass it. The client consumes the replicated lock before
publishing its mount interaction and checks it again before the mounting request, so a rejected
entry does not briefly put the player inside. triggerHorn accepts 100..2000 milliseconds
(default 250) and sends a reliable authority-epoch-bound command to the current physics owner.
Parked vehicles deliberately retain owner 0, so their command is sent directly to every current
viewer without granting a physics lease. Horn state is transient and is mirrored to observers by
the regular vehicle motion stream rather than persisted.
The server vehicle registry is shared across resources. Any resource granted world.vehicles may
read, mutate, repair, paint, move, seat players in, or remove any canonical vehicle, regardless of
which resource created it. The snapshot resource field is provenance and automatic cleanup scope,
not an authorization boundary.
Body, glass, lights, and tires
| Function | Signature | Purpose |
|---|---|---|
setBodyDamage |
(id, values[30]) |
Replace all normalized body cells. |
setBodyCell / damageBodyCell / repairBodyCell |
(id, cell, value?) |
Set, add to, or clear cell 1β30. |
setBodyZone / damageBodyZone / repairBodyZone |
(id, zone, value?) |
Mutate a named/profile zone or explicit cell array. |
setGlassMask |
(id, mask) |
Replace the 32-bit broken-glass mask. |
setGlassBroken |
(id, glass, broken) |
Mutate one numeric or profile-named pane. |
breakGlass / repairGlass |
(id, glass) |
Convenience pane mutation. |
breakAllGlass / repairAllGlass |
(id, count?) / (id) |
Break the first 0β32 panes or clear the mask. |
setLightMask |
(id, mask) |
Replace broken-light mask. |
setLightBroken |
(id, index, broken) |
Mutate one light bit. |
breakLight / repairLight / repairAllLights |
(id, index?) |
Light convenience methods. |
setTireMask |
(id, mask) |
Replace four-wheel broken-tire mask. |
setTireBroken |
(id, index, broken) |
Mutate tire index 0β3. |
breakTire / repairTire / repairAllTires |
(id, index?) |
Tire convenience methods. |
registerDamageProfile |
(record, profile) |
Register record-specific glass names and body zones in this VM. |
getDamageProfile |
(record) |
Return this resource's registered profile or nil. |
Default Open77.vehicles.bodyZones are backLeft, back, backRight, left, center, right,
frontLeft, front, frontRight, lower, roof, and all.
Doors and windows
| Function | Signature | Purpose |
|---|---|---|
setDoorMask |
(id, mask) |
Replace the six-bit opening mask. |
setDoorOpen |
(id, door, opened) |
Set one opening. |
openDoor / closeDoor |
(id, door) |
Convenience mutation. |
isDoorOpen |
(id, door) |
Boolean or nil for unknown vehicle. |
setWindowOpen |
(id, window, opened) |
Set one window opening. |
openWindow / closeWindow |
(id, window) |
Convenience mutation. |
isWindowOpen |
(id, window) |
Boolean or nil for unknown vehicle. |
Door names are frontLeft, frontRight, backLeft, backRight, trunk, and hood; windows
use the four side names. Constants live in Open77.vehicles.doors and .windows. State flags live
in .flags: engineOn, locked, destroyed, exploded, invulnerable, immortal, lightsOn,
highBeams, sirenOn, and paintApplied. Prefer setPaint/resetPaint over changing the paint bit
directly.
Detached panels
| Function | Signature | Purpose |
|---|---|---|
setDetachedPartMask |
(id, mask) |
Add a monotonic 16-bit detached-part mask. |
setPartDetached |
(id, part, true) |
Detach one standard named/indexed panel. |
detachPart |
(id, part) |
Convenience detachment method. |
isPartDetached |
(id, part) |
Read one canonical detachment bit. |
Names are exposed in Open77.vehicles.detachedParts. Live reattachment is deliberately rejected
because REDengine 2.31 has no validated safe inverse of DetachPart; respawn the vehicle instead.
Autonomous driving
Open77.vehicles.ai attaches a native driver to a canonical vehicle and gives it orders:
attachDriver(id, options), removeDriver(id), state(id), driveTo, followRoute, follow,
chase, joinTraffic, stop, setSpeed, setBehavior, and on(event, handler) for the
open77:vehicles:ai:* events. Every one of them is one VehicleDrivingCommand call underneath β
that global is the low-level alias for the whole table. See vehicle AI for the
options, the behaviour vocabulary and the events.
Low-level aliases are CreateVehicle, UpdateVehicleState, SetVehiclePaint,
ResetVehiclePaint, SetVehicleLocked, TriggerVehicleHorn, SetVehicleTransform, RemoveVehicle, GetVehicle, GetVehicles,
SetPlayerIntoVehicle, ForcePlayerOutOfVehicle,
SetPlayerVehicleExitLocked, and GetPlayerVehicleSeat. The namespaced API supplies validation,
options, aliases, and damage helpers and is the recommended surface. See vehicles for
snapshots, streaming, authority and seats.
NPCs and tasks
All methods require world.npcs.
NPC lifecycle and state
| Function | Signature | Purpose |
|---|---|---|
Open77.npcs.create |
(definition) |
Create an authoritative NPC from record = "Character.*" (no catalogue required), or a legacy template alias. Returns ID or nil, reason; engine readiness is asynchronous. See NPCs. |
Open77.npcs.update |
(id, patch) |
Patch appearance, loadout, behavior, AI mode, damage policy, health, or ragdoll. |
Open77.npcs.setTransform |
(id, transform) |
Set canonical position and yaw. |
Open77.npcs.setBucket |
(id, bucket) |
Move NPC visibility scope. |
Open77.npcs.setAppearance |
(id, appearance) |
Appearance convenience patch. |
Open77.npcs.setLoadout |
(id, loadout) |
Loadout convenience patch. |
Open77.npcs.setHealth |
(id, health, maxHealth?) |
Health convenience patch. |
Open77.npcs.setDamagePolicy |
(id, policy) |
Set mortal/immortal/invulnerable policy. |
Open77.npcs.setAiMode |
(id, mode) |
Set tasks/frozen/native mode. |
Open77.npcs.setBehavior / getBehavior |
(id, partialOptions) / (id) |
Update/read native AI, combat, perception and voice policy. See NPC behavior. |
Open77.npcs.setAIEnabled |
(id, enabled) |
Pause/resume the native AI agent and Open77 scheduler without changing aiMode. |
Open77.npcs.setCombatEnabled |
(id, enabled) |
Suppress/allow combat and hostile acquisition. |
Open77.npcs.setPerceptionEnabled |
(id, enabled) |
Suppress/allow autonomous sensory acquisition. |
Open77.npcs.setVoiceEnabled |
(id, enabled) |
Suppress/allow per-NPC voice lines and barks. |
Open77.npcs.setRagdoll |
(id, enabled) |
Set canonical ragdoll state. |
Open77.npcs.setAttitude / getAttitude |
(id, attitude, options?) / (id) |
Make an NPC hostile, neutral or friendly toward one player, one owned NPC, one group, or everyone. Per NPC and per target. See NPCs. |
Open77.npcs.setGroup / getGroup |
(id, group) / (id) |
Put an NPC in a relationship group (the pre-existing combat.group field). |
Open77.npcs.setRelationship / getRelationship |
(groupA, groupB, attitude) / (groupA, groupB) |
Declare how two of this resource's groups regard each other. Per resource, symmetric, dies with the resource. |
Open77.npcs.target |
(id) |
Who the NPC is currently fighting, as its lease holder's engine reports it. |
Open77.npcs.kill / revive / applyDamage |
(id, ...) |
Authoritative life mutations. |
Open77.npcs.remove |
(id) |
Remove the NPC. |
Open77.npcs.get / all |
(id) / (bucket?) |
One snapshot or an array. |
Open77.npcs.templates |
() |
Legacy alias catalogue, not a limit on direct Character records. |
Definition fields include record, template, position, yaw, bucket, appearance, loadout, behavior,
aiMode, damagePolicy, health, maxHealth, streamingRadius, streamingHysteresis,
despawnWhenUnobserved, and persistent.
Constants are Open77.npcs.flags, .ai, .damage, and .channels.
Task queue
| Function | Signature | Purpose |
|---|---|---|
Open77.npcs.tasks.enqueue |
(id, type, parameters?, options?) |
Enqueue any supported task. |
cancel |
(id, taskId) |
Cancel one task. |
clear |
(id, channel?) |
Clear queued/current tasks. |
get |
(id, taskId) |
Read one task. |
all |
(id) |
List tasks for an NPC. |
moveTo |
(id, position, options?) |
Move with speed, acceptance radius, priority, and timeout. |
follow |
(id, target, options?) |
Follow a player, NPC, or position. |
patrol |
(id, points, options?) |
Patrol with loop/back-and-forth options. |
wander |
(id, options?) |
Wander using the supplied movement parameters. |
face |
(id, position, options?) |
Rotate toward a world point. |
lookAt |
(id, target, options?) |
Drive the look channel toward player/NPC/position. |
wait |
(id, durationMs, options?) |
Timed action-channel wait. |
hold |
(id, options?) |
Hold movement indefinitely or until timeout/cancel. |
playAnimation |
(id, animation, options?) |
Run a full-body animation task. |
attack |
(id, target, options?) |
Engage a player or an owned NPC. Action channel, so it composes with a standing movement task. |
guard |
(id, position, radius?, options?) |
Hold an area, returning to the anchor only when not already in a fight. |
flee |
(id, from, options?) |
Break contact with a player, NPC or fixed point. |
workspot |
(id, reference, options?) |
Play an authored scenario: a catalogue profile id or a raw clip, with durationMs or loop. |
workspots |
(query?) |
The shipped scenario profiles -- the same catalogue Open77.animations.list() serves. |
enterVehicle |
(id, vehicleId, seat?, options?) |
Walk to a vehicle and take a seat; warp skips the walk. |
exitVehicle |
(id, options?) |
Leave the seat. Idempotent; vehicleId narrows it to one car. |
Five further verbs address driving by ped id. They resolve the vehicle the NPC is the attached
driver of and forward to Open77.vehicles.ai, which owns the vanilla driving pipeline -- there is no
second driving stack. All of them require both world.npcs and world.vehicles, and all of them
answer nil, "npc_not_driving" until Open77.vehicles.ai.attachDriver(vehicleId, { npcId = id })
has run.
| Function | Signature | Purpose |
|---|---|---|
Open77.npcs.tasks.driving |
(id) |
The driving job this NPC drives, or nil, reason. |
driveTo |
(id, position, options?) |
Drive to a point with speed and style. |
driveWander |
(id, options?) |
Join traffic and follow the road network indefinitely. Not a free-roam wander: Cyberpunk AI drives authored lanes. |
chase |
(id, target, options?) |
Pursue a player, vehicle or NPC. |
stopDriving |
(id) |
Stop the current driving job. |
setDriverAbility |
(id, ability) |
Map a 0..1 ability onto behaviour and speed cap. 2.31 has no skill parameter; the mapping is documented in NPCs. |
Low-level aliases are CreateNpc, UpdateNpc, SetNpcTransform, SetNpcBucket, RemoveNpc,
GetNpc, GetNpcs, EnqueueNpcTask, CancelNpcTask, ClearNpcTasks, GetNpcTask, GetNpcTasks,
KillNpc, ReviveNpc, DamageNpc, GetNpcTemplates, SetNpcAttitude, GetNpcAttitude,
SetNpcGroup, SetNpcRelationship, GetNpcRelationship, and GetNpcTarget. See
NPCs for the task state machine, ownership, streaming, templates, and events.
onNpcTargetChanged(npcId, kind, targetId, previousKind, previousTargetId) fires when an NPC
acquires or loses a target, and is delivered only to the resource that owns it.
Elevators
All methods require world.elevators.
| Function | Signature | Purpose |
|---|---|---|
Open77.elevators.adopt |
(definition) |
Adopt a native lift and return an Open77 elevator ID. |
goTo / call |
(id, floor, options?) |
Start authoritative travel; options include travelMs and force. |
teleport |
(id, floor) |
Administrative recovery without travel. |
pause / resume |
(id) |
Pause or continue authoritative movement. |
setFlags |
(id, flags) |
Update power, lock, interaction, and door policy. |
remove |
(id) |
Release the managed elevator. |
get / all |
(id) / (bucket?) |
Read canonical snapshots. |
adopt accepts engineEntity, position, bucket, initialFloor, flags, and required
floorCount. Constants are in Open77.elevators.flags. Low-level aliases are AdoptElevator,
GoToElevator, TeleportElevator, PauseElevator, ResumeElevator, SetElevatorFlags,
RemoveElevator, GetElevator, and GetElevators. See elevators.
Voice topology and policy
All methods require voice.manage. The dedicated server owns reachability: a client's requested
scope never bypasses routing buckets, proximity, membership, mute/deaf, rate, or quality checks.
| Method | Purpose |
|---|---|
Open77.voice.status |
Global quality/profile, default proximity, and relay/rejection counters. |
channels / getChannel |
List/read canonical radio, phone, party, admin, and spatial channels. |
participants / getParticipant |
Read proximity, server mute/deaf, and channel memberships. |
createChannel / updateChannel / removeChannel |
Resource-owned topology and effect control. |
addPlayer / removePlayer |
Change channel membership. |
setChannelPlayerMuted |
Mute one player in one channel. |
setChannelPlayerPermissions |
Set independent speak/listen permission. |
setPlayerMuted / setPlayerDeaf |
Server-wide send/receive policy. |
setProximity |
Enable/disable and tune one player's server-side reach. |
setDefaultProximityDistance |
Change the default, optionally for existing participants. |
setQuality / setEnabled |
Set global codec policy or disable voice. |
Channel effects include gain, lowPassHz, highPassHz, distortion, radioNoise,
spatialBlend, and the reverb controls reverbWet, reverbRoomSize, reverbDecay,
reverbDamping, and reverbPreDelayMs. Non-persistent channels are released automatically with
their owning resource.
See Integrated voice chat for complete signatures, examples, client APIs, settings, and
the security/bandwidth model.
Ambient population
Open77.world.setPopulation requires world.population; getPopulation is ungated.
An unconfigured bucket has no ambient crowd, traffic or police. Enable population explicitly with the density settings or bucket.population <bucket> on.
| Function | Signature | Purpose |
|---|---|---|
Open77.world.setPopulation |
(bucket, { crowd?, traffic?, police? }) |
Ambient density for one routing bucket, replicated to every client in it. |
getPopulation |
(bucket) |
Read it back, including what the engine can actually apply. |
-- An empty staging instance, with no cops to wander into it.
Open77.world.setPopulation(4, { crowd = 0, traffic = 0, police = false })
-- Half the usual pedestrians, vanilla traffic.
Open77.world.setPopulation(0, { crowd = 0.5, traffic = 1, police = true })
What is a fraction and what is a switch
crowd is a genuine multiplier on the vanilla pedestrian density and is applied as one, through the
engine's community density modifier. traffic is accepted as 0..1 so the API does not have to
change if a vehicle equivalent is ever found, but on 2.31 there is none: any value above zero
means vanilla traffic. getPopulation states this rather than leaving a caller to discover it --
crowdGranularity is continuous, trafficGranularity is binary.
police allows or forbids vanilla prevention spawns outright.
enabled is derived (crowd > 0 or traffic > 0 or police), so it can never claim the
population is on while every density is zero -- and an unconfigured bucket reads 0 / 0 / false,
as the section opens with.
What the client does with it
Two client mechanisms remove vanilla bodies while a session is active, and both follow the policy:
the vehicle spawn policy (which vanilla spawns are allowed to complete) and the identity sanitizer
(which unowned runtime NPCs and vehicles are swept, so that a body nothing owns is never mistaken
for a replicated one). A kind the policy allows -- pedestrians when crowd > 0, vehicles when
traffic > 0 -- is scenery, replicates to nobody, and is left alone by both. Until client 67 the
sanitizer ignored the policy, which is why a bucket with traffic = 1 on release 65 showed cars
that vanished half a second after they spawned; from client 68 on, pop in the developer console
reports the bodies it kept for the policy as sanitizerAllowedByPolicy.
Open77.routingBuckets.setPopulationEnabled is the same state expressed as a switch: off sets all
three off, on restores the vanilla figures.
The client half is read-only
Clients receive the policy for their own bucket and read it with Open77.world.population(). There
is no client setter, because density is a server-load decision that has to be the same for everyone
in a bucket.
That one-way shape is also why this needs no intent window, unlike the vehicle electrical flags: the sirens problem was a server write landing in a field the owning client republishes twice a second. No client message carries ambient density at all, so there is nothing to overwrite it. What does need ordering is a replay -- a bucket change re-sends the policy, and that replay can be older than a write that already landed -- so every message carries a revision monotonic across all buckets and the client drops anything older than what it holds.
Refusals
| Reason | Meaning |
|---|---|
permission_denied:world.population |
the resource does not hold the permission |
invalid_bucket |
not a non-negative integer |
invalid_crowd / invalid_traffic |
outside 0..1, or not a number |
invalid_options |
the second argument was not a table, or police was not a boolean |
Clearing an area
Open77.world.clearArea(position, radius, options?) removes the server-owned entities inside a
sphere and can suppress vanilla population there. It is the server half of Open77.world, and the
only function in it: the client's world table (raycast, groundZ, nearby, nearest,
district) needs a physics world and a streamed player, and a dedicated server has neither.
| Function | Signature | Result |
|---|---|---|
Open77.world.clearArea |
(position, radius, options?) |
{ npcs, vehicles, props, refused, foreignVehicles, population }, or nil, reason. |
Options are npcs, vehicles, props (booleans, all default true), population (default
false), bucket, foreign (default false) and grace, metres of outward slack. A nil
bucket means every bucket β the same reading Open77.zones.playersIn takes, since a place is not
a player and has no bucket of its own to borrow. The containment test is Open77.zones.contains
from the shared zone module, so the radius cap (2,000 m) and the geometry rules are the ones every
other Open77 zone obeys.
Failures are permission_denied:world.clearArea.foreign, invalid_radius, invalid_position,
invalid_options, invalid_bucket, bucket_required_for_population, and the shared zone-module
tokens such as invalid_grace.
It removes what you own, and counts the rest
This is the one world call that destroys state another resource created and still holds ids for. A
gamemode resetting a street would otherwise delete the parked cars a roleplay resource spawned, and
that resource would find out only when its own ids began answering not_found, with nothing in any
log tying the two together.
So clearArea removes only entities whose owner is the calling resource. Anything else inside the
radius is counted in refused and left standing.
-- Event setup: take down my own props and cars, leave everyone else's alone.
local swept = Open77.world.clearArea({ x = -1540, y = -2020, z = 24 }, 40, { bucket = 7 })
print(("cleared %d npcs, %d vehicles, %d props; left %d belonging to other resources")
:format(swept.npcs, swept.vehicles, swept.props, swept.refused))
foreign = true lifts that rule, and it asks twice: the flag at the call site and
world.clearArea.foreign in the manifest, which a server operator has to have read and granted.
Without the permission the whole call is refused rather than quietly downgraded to an own-only
sweep β a silent downgrade would report success on a street that still has somebody else's cars in
it.
The permission reaches vehicles and only vehicles, and that is structural rather than a policy choice worth arguing with:
| Kind | Foreign entity, with the permission |
|---|---|
| Vehicles | Removed, and counted in foreignVehicles. |
| Props | Refused. PropAuthorityService own-checks its own removals. |
| NPCs | Never even a candidate: the server's NPC read is owner-scoped, so a foreign NPC is invisible to the sweep and is not counted in refused either. |
A missing world.npcs, world.vehicles or world.props skips that kind and the call still
succeeds. A teardown that failed outright because one of three permissions was absent would leave
the other two kinds behind, which is the opposite of what a teardown is for.
Population suppression is per bucket, not per radius
population = true requires a bucket and drives Open77.routingBuckets.setPopulationEnabled,
the same switch the shipped gamemodes use. The suppression that follows is bucket-wide and is not
confined to the radius. There is no radius-scoped vanilla-population control on 2.31; saying so
is better than implying a precision that does not exist, and a bucket-less call is refused with
bucket_required_for_population rather than guessing which of "suppress everywhere" or "do
nothing" was meant.
World props
Every method requires world.props. Props are authoritative, owned by their creating resource,
and streamed per player rather than broadcast to a bucket. IDs are decimal strings; do not pass
them through tonumber.
| Function | Signature | Result |
|---|---|---|
Open77.props.create |
(definition) |
Open77 prop ID, or nil, reason. |
Open77.props.update |
(id, patch) |
boolean, reason? |
Open77.props.setTransform |
(id, { position?, yaw?, scale? }) |
boolean, reason? |
Open77.props.setBucket |
(id, bucket) |
boolean, reason? |
Open77.props.attach |
(id, { kind, id }, { offset?, yaw?, trackYaw?, detachOnDeath?, followBucket? }) |
boolean, reason? |
Open77.props.detach |
(id) |
boolean, reason? |
Open77.props.remove |
(id) |
boolean, reason? |
Open77.props.get |
(id) |
Prop snapshot, or nil. |
Open77.props.all |
(bucket?) |
Array of prop snapshots, optionally filtered to one bucket. |
Open77.props.catalog |
() |
Curated model aliases and the models they resolve to. |
Open77.props.clear |
() |
Remove every prop this resource owns; returns how many. |
Definitions accept model, position, yaw, scale, appearance, bucket, physics
(static, kinematic, dynamic, none), collision, visible, kind (prop, light,
effect), light, streamingRadius (10β2000), streamingHysteresis (0 to the radius), and
ttlMs (0 for no expiry, seven days maximum). update is sparse and does not accept model or
kind; changing either is a remove and a create. The registry holds 8,192 props, of which one
resource may own 2,048. A resource may read every prop but may only mutate or remove its own.
Failures are permission_denied:world.props, quota_exceeded, not_found,
owned_by_another_resource, invalid_position, invalid_model, unknown_alias,
record_provisioning_failed, entity_spawn_failed, and world_unavailable.
attach makes a prop follow a player, an NPC or a vehicle: the server recomputes its
transform from the target's ten times a second and replicates it as an ordinary prop upsert.
It does not sit in the hand, and setTransform on an attached prop is refused with
prop_attached. A target that disconnects, dies or leaves ends the follow and leaves the
prop where it stood; a routing-bucket change is followed. For something that must be held,
use Open77.heldItems.hold instead -- see props for the difference.
Low-level aliases are CreateProp, UpdateProp, SetPropTransform, SetPropBucket,
AttachProp, DetachProp, RemoveProp, GetProp, GetProps, and ClearProps. The namespaced API supplies validation and structured
option tables and is the recommended surface. See props for the model catalogue,
streaming rules, lights, the client projection API, and the current limitations.
World effects
Every method requires world.effects. A one-shot is broadcast to the players in range and never
stored; a looping effect is a registry entry with the same identity, ownership, revisioning and
streaming semantics as a prop, in a registry of its own.
| Function | Signature | Result |
|---|---|---|
Open77.effects.play |
(name, opts) |
boolean, reason? β one-shot, no handle. |
Open77.effects.create |
(definition) |
Open77 effect ID, or nil, reason. |
Open77.effects.update |
(id, patch) |
boolean, reason? |
Open77.effects.remove |
(id) |
boolean, reason? |
Open77.effects.all |
(bucket?) |
Array of looping-effect snapshots, optionally filtered to one bucket. |
Open77.effects.catalog |
() |
Curated effect aliases. |
Open77.effects.get |
(id) |
Looping-effect snapshot, or nil. |
Open77.effects.playOn |
(entityOrPlayerId, name, opts) |
boolean, reason? β entity-authored VFX. |
Open77.effects.sound |
(entityOrPlayerId, event, opts) |
boolean, reason? β entity-bound Wwise event. |
Open77.effects.attach |
(target, effect, opts) |
Open77 effect ID, or nil, reason β a looping effect bound to an entity slot. |
Open77.effects.screen / PlayerScreenEffect |
(playerId, name, opts?) |
boolean, reason? β a full-screen post-process on ONE player. Not replicated; permission players.screenfx. name false clears. See Screen effects. |
Open77.effects.clearScreen |
(playerId) |
boolean, reason? β clears every server-driven screen effect on that client. |
Open77.effects.screenCatalog / GetScreenEffectCatalog |
() |
The screen-effect aliases this server accepts, ascending. |
attach is the difference between an effect that plays on something and one that belongs to it.
playOn is fire-and-forget: nothing is stored, and a player who streams in a second later sees
nothing. attach creates a registry entry with an id, an owning resource and per-player streaming,
so it follows the entity and a late joiner sees it too. target is
{ kind = "player"|"npc"|"vehicle"|"prop", id = ... } and opts.slot is required; localAnchor
(default body), localSlot (defaults to slot), ttlMs, streamingRadius (default 90),
streamingHysteresis (default 20), localEvent, soundEvent and soundOnOwner are optional. The
low-level alias is AttachEffect.
play options are position, orientation, bucket, range (1β500, default 150), and
sound. create definitions accept effect, position, orientation, bucket, visible,
streamingRadius (10β2000, default 90), streamingHysteresis (default 20), and ttlMs. The
effect name is not patchable. The registry holds 2,048 looping effects, of which one resource may
own 512.
Low-level aliases are PlayEffect, CreateEffect, UpdateEffect, RemoveEffect,
GetEffect, GetEffects, PlayEntityEffect, and PlayEntitySound. The namespaced API
supplies validation and structured option tables and is the recommended surface.
Failures use the same vocabulary as props with permission_denied:world.effects in place of the
props permission.
There are no low-level aliases for Open77.effects; the namespaced table is the whole surface.
The client-local Open77.vfx and Open77.sfx tables are a different API in a different
runtime and are not replicated β see effects for the distinction, which is the
easiest thing to get wrong here.
Database
Database access requires database.access. Open77.database and MySQL refer to the same
oxmysql-compatible table.
| Method | Callback form | Await form |
|---|---|---|
query / prepare |
(sql, params?, callback?) |
.await(sql, params?) returns rows. |
single |
(sql, params?, callback?) |
.await returns one row or nil. |
scalar |
(sql, params?, callback?) |
.await returns one scalar. |
insert |
(sql, params?, callback?) |
.await returns inserted ID/result. |
update / rawExecute |
(sql, params?, callback?) |
.await returns affected-row result. |
transaction |
(statements, callback?) |
.await(statements) returns true or false, reason. |
Callbacks and .await continuations resume on the owning resource's scheduler, never on the
database worker. See Configure a SQL database for installation, credentials,
configuration, a Lua connection probe and troubleshooting. The
platform database guide
details parameter forms, limits and transactions.
Waiting for the database
| Method | Signature | Result |
|---|---|---|
ready |
(handler) |
true once the handler is queued or run, or false, reason. |
isReady |
() |
true, or false, reason. |
MySQL.ready(fn) is oxmysql's boot gate: put schema creation or a migration in it instead of
racing it against the first connection. The host opens one connection at startup and runs
SELECT 1 on it; readiness is that answer, not a guess from the configuration file.
Three states, not a boolean, and that is the whole of the design. A server started with no
database bridge at all can never become ready, so ready refuses it with
database_unavailable and the handler never runs β running it would hand the resource a
bridge whose every query fails. A database still connecting, or configured but not answering,
can still become ready: the probe retries with a backoff up to 30 s, so the handler is
queued and a database that comes up ten minutes late still fires it. isReady() answers
false, "database_connecting" or false, "database_unreachable" for those two, and never
true for a database that is down.
A handler registered after readiness runs anyway, on the next tick. A resource never has to ask whether it started before or after the database came up, and the late path is scheduled rather than inline so it behaves exactly like the early one.
MySQL.ready(function()
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS garage (plate VARCHAR(16) PRIMARY KEY, owner BIGINT NOT NULL)
]])
print("garage schema ready")
end)
Readiness is one-way. A later outage is a failing request, and every request already
reports its own failure; flipping back would make ready fire twice for one boot, which no
oxmysql resource expects.
Both need database.access, the same capability the queries need, and answer
permission_denied:database.access without it. Gating readiness separately and more cheaply
would let a resource that may not query the database still learn whether the operator runs one.
ready also answers invalid_argument for anything that is not a function.
Admin events
Warden used to act in silence: a resource learned a player had been banned only by watching them disconnect, and learned a restart was coming not at all. Eight events now describe the operator's own acts, emitted by the host and by Warden into every running resource.
| Event | Signature | Raised when |
|---|---|---|
open77:admin:scheduledRestart |
(secondsRemaining, reason) |
A restart is armed, and again on each rung of the warning ladder (30m, 15m, 10m, 5m, 2m, 60s, 30s, 10s). |
open77:admin:restartCancelled |
() |
A pending restart is disarmed. Not raised when nothing was armed. |
open77:admin:serverShuttingDown |
(reason) |
The loop is stopping. reason is restart, reset or shutdown. |
open77:admin:announcement |
(text) |
An operator announcement went out. |
open77:admin:playerWarned |
(playerId, author) |
A warning was delivered to that player. |
open77:admin:playerKicked |
(playerId, author) |
A player was removed, other than as part of a ban. |
open77:admin:playerBanned |
(playerId, author, durationSeconds) |
A player was banned. durationSeconds is empty for a permanent ban. |
open77:admin:playerHealed |
(playerId, author) |
An operator healed a player from Warden's Players tab. Not raised by a resource's own Open77.players.heal. |
AddEventHandler("open77:admin:scheduledRestart", function(secondsRemaining, reason)
if tonumber(secondsRemaining) <= 60 then FlushEverythingNow() end
end)
AddEventHandler("open77:admin:serverShuttingDown", function(reason)
print(("server going down (%s)"):format(reason))
end)
What they carry, and what they deliberately do not
These events are host-wide: every running resource receives one, whatever it is about. So the payload is the minimum that lets a resource react, and no more.
The moderation reason never travels. A warning's text and a kick's or ban's reason are
addressed to one player; a host-wide event naming them would hand every resource on the server
a running moderation log. This is the same rule onAclChanged follows when it carries a
revision and an operation but never the subject or the permission.
The operator is never named. author is provenance, not identity β which channel acted,
not which member of staff. It has two values:
author |
Meaning |
|---|---|
warden |
The operator panel or the server console. |
resource |
A running resource, through Open77.players.kick or Open77.players.ban. |
That answers the question a resource actually has β "did staff remove this player, or did a script?" β without turning the bus into a staff activity feed.
A ban is reported once. Banning removes the player, but it is one moderation act:
playerBanned is raised and the removal underneath it is not also reported as
playerKicked. A resource counting removals would otherwise count every ban twice. The
duration does travel, because it is what decides whether a resource purges a player's saved
state or keeps it warm.
The announcement text and the restart reason do travel. Both are already broadcast to every player's chat, so withholding them would protect nothing and would cost a resource the ability to mirror an announcement to Discord.
None of them is cancellable
They are plain notifications, raised with the ordinary host emitter and never with the bus's
cancellable path, so CancelEvent() inside one of these handlers does nothing. They are named
as notifications for that reason β scheduledRestart, not restartRequested β so that no
handler is invited to try.
A resource that must stop a restart calls Open77.runtime.cancelRestart(), which costs
runtime.restart. Cancelling a shutdown is an authority, not a veto every listener gets for
free.
Driving the restart from a resource
| Function | Permission | Result |
|---|---|---|
Open77.runtime.scheduleRestart(seconds, reason?) |
runtime.restart |
{ scheduled, secondsRemaining, reason? }, or nil, reason. |
Open77.runtime.cancelRestart() |
runtime.restart |
{ scheduled = false, secondsRemaining = 0 }, or nil, reason. |
Open77.runtime.restartStatus() |
runtime.restart |
{ scheduled, secondsRemaining, reason? }, or nil, reason. |
runtime.restart is its own capability, not resources.control: stopping one resource and
stopping the whole server are different powers, and an operator who granted the first did not
thereby grant the second.
A resource-scheduled restart takes exactly the path a Warden-scheduled one takes β the same broadcast now, the same warning ladder, the same events β because a restart players were not warned about is the failure this surface exists to prevent. Calling it again re-arms the schedule rather than adding a second one.
restartStatus() is the read the event cannot replace. An event announces a change, and
a resource that started after the restart was scheduled never saw one. Ask once at start, then
subscribe:
AddEventHandler("onResourceStart", function(name)
if name ~= GetCurrentResourceName() then return end
local status = Open77.runtime.restartStatus()
if status and status.scheduled then ArmSaveTimer(status.secondsRemaining) end
end)
seconds must be a whole number in [1, 604800]; the scheduler clamps anything under five
seconds up to five. reason is broadcast to players, so it is capped at 200 bytes and must
carry no control characters. Refusals: permission_denied:runtime.restart, invalid_delay,
invalid_reason, restart_control_unavailable.
What a shutdown notice can and cannot promise
open77:admin:serverShuttingDown is emitted after the loop stops, and the host then pumps a
small fixed number of resource ticks so the handlers actually run β an event queued into a VM
that is never ticked again would be no notice at all.
That budget is small and fixed on purpose: a shutdown that waits on resources is a shutdown one
broken resource can hold open, and a supervisor restarting the process does not wait politely.
A handler that saves synchronously completes; a handler that yields for long is not
guaranteed to finish. The reliable place to persist state is onResourceStop, which every
resource still receives from its own VM as the host tears it down.
Reserved, as a whole namespace
No resource may publish anything under open77:admin:; TriggerEvent answers
false, "reserved_event". The namespace means one thing β the operator's own surface did
this β and that is exactly the claim an attacker wants to make. A forged playerBanned would
tell an audit resource that staff removed a player nobody removed; a forged
serverShuttingDown would make every resource on the server flush and stop saving.
An admin resource's own acts belong in its own namespace, because they are its acts and not
the platform's. That is the line open77:admin:playerHealed walks: it is raised when an operator
heals from Warden's own Players tab β the operator's surface acting, which is
exactly what the namespace is for β and it is not raised when a resource calls
Open77.players.heal, because that is a gameplay native any resource may call, and announcing
every call of it as an administrative act would be both a firehose and a lie. A resource that
heals on its own admin command should publish its own event, under its own namespace, saying so.
Outbound HTTP
PerformHttpRequest (alias Open77.http.request) requires the http.request permission and
a server that enabled the bridge with an allow-list. Same shape as the database bridge: the
request leaves the tick thread at once, the exchange runs on the thread pool, and the callback
runs on the owning resource's own tick.
PerformHttpRequest("https://discord.com/api/webhooks/β¦", function(status, body, headers, err)
if status == 0 then print("webhook failed: " .. tostring(err)) return end
print(("webhook answered %d"):format(status))
end, "POST", { content = "Race heat finished" }, { ["Content-Type"] = "application/json" })
| Argument | Meaning |
|---|---|
url |
Absolute http:// or https:// URL, no credentials, host on the server's allow-list. |
callback |
(status, body, headers, err). status is the HTTP status code, or 0 when the exchange did not complete; err then names the reason. Optional. |
method |
GET (default), POST, PUT, PATCH, DELETE, HEAD. |
body |
A string, or a table that is JSON-encoded for you. At most 256 KiB. |
headers |
{ name = value }, at most 32; no CR/LF. |
Returns true when the request was accepted, else false, reason; a refused request still
invokes the callback with status = 0 and the reason, so a script never waits on it.
Reasons: permission_denied:http.request, http_unavailable (bridge off on this server),
invalid_url, host_not_allowed, invalid_method, body_too_large, too_many_headers,
invalid_header, too_many_requests (16 in flight per resource); completion errors timeout,
response_too_large, request_failed.
Operator side (server.jsonc):
"http": {
"enabled": true,
"allowedHosts": ["discord.com", "*.example.com"],
"timeoutSeconds": 10,
"maxResponseBytes": 1048576
}
An exact entry matches that host only; *.example.com matches one level of subdomain and the
bare host. An empty list reaches nothing. Redirects are never followed, so the list is the whole
reachable surface.
Serving HTTP
Open77.http.listen(prefix, handler) serves inbound HTTP under /<resource>/<prefix> on the
server's own listener. It requires http.serve -- a different power from http.request: the
network calling a resource rather than the resource calling out, and an operator reading a
manifest should see which of the two a resource wants. The listener is a server-wide opt-in, off
by default and loopback by default:
"httpHandlers": { "enabled": true, "listenUrl": "http://127.0.0.1:11781", "timeoutSeconds": 5 }
When disabled, listen returns http_handlers_unavailable. The listener uses plain HTTP; place a TLS reverse proxy in front of any publicly exposed route.
-- open77.lua: permissions { "http.serve" }
-- POST /my_resource/heal { "playerId": 3 }
local route = Open77.http.listen("/heal", function(req, res)
if req.method ~= "POST" then return res.json(405, { error = "method_not_allowed" }) end
local payload = json.decode(req.body or "") or {}
local ok, reason = Open77.players.setHealth(payload.playerId, 100)
if not ok then return res.json(404, { error = reason }) end
res.json({ healed = payload.playerId })
end)
print("serving " .. tostring(route)) -- /my_resource/heal
| Routing | Routes are namespaced by resource (/<resource>/β¦); a resource can never claim another's segment. The longest registered prefix wins; listen("/", β¦) -- or FiveM's SetHttpHandler(handler) -- takes everything under the resource. unlisten(prefix) removes one route; stop and reload drop them all. routes() lists this resource's public routes. |
| The handler | Runs on the resource's own tick, one tick after the request landed -- never cross-thread, so it may call any other API. req = { method, path, query, body, route, remoteAddress?, headers }; header lookup is case-insensitive. |
| The answer | res.send(status, body, headers?) -- a table body is JSON-encoded with a JSON content type -- or the sugar res.json(status?, value) / res.text(status?, text). Exactly once: a second call answers false, already_sent. A handler that throws answers 500 {"error":"handler_error"}; one that never answers is timed out by the host after timeoutSeconds. |
| Bounds | Bodies over 16 KiB are refused with body_too_large before Lua sees them; 16 requests in flight per resource, the rest get 503; headers capped like the outbound side. |
Reasons: permission_denied:http.serve, http_handlers_unavailable, invalid_prefix,
invalid_handler, route_not_found (unlisten on a route this resource never registered).
Logging
Open77.log.debug, .info, .warn and .error write to the resource-prefixed server logger at
their own level -- DBG, INF, WRN, ERR in the log line, the same four the client has -- so an
operator can filter a resource's warnings from its chatter. print stays INF, Citizen.Trace is
debug. Their common signature is (...), values are joined with a tab like print, and no value
is returned. Control sequences (ANSI colour codes, cursor moves) are stripped from every resource
line before it reaches the log, so a ported script that colours its output cannot corrupt the
terminal or the log file.
Latent (chunked) client events
A net event carries at most 48 KiB of JSON. TriggerLatentClientEvent(name, target, bytesPerSecond, ...) -- alias Open77.net.emitLatent -- is the same call as TriggerClientEvent
with FiveM's rate argument in third place, for a payload the envelope cannot carry: up to 4 MiB,
cut into 40 KiB frames on a reserved name, paced at the requested rate, reassembled by the client
host and delivered to RegisterNetEvent handlers under the original name. The receiving resource
cannot tell it was latent, so a ported inventory or catalogue push needs no client change. Requires
network.events.
-- Push the whole item catalogue to a joining player without stalling their session
local id, reason = TriggerLatentClientEvent("shop:catalogue", playerId, 256 * 1024, catalogue, revision)
if not id then return print("catalogue not sent: " .. tostring(reason)) end
CreateThread(function()
while true do
Wait(1000)
local s = Open77.net.latentStatus(id)
if not s or s.state ~= "sending" then print("catalogue " .. (s and s.state or "gone")); return end
end
end)
target |
one player id, or -1 for every connected player (one stream per recipient, one status). |
| Rate | clamped to 1 KiB/s .. 2.5 MiB/s; all of a resource's streams together never exceed 64 frames per second, inside the per-player event budget. |
Open77.net.latentStatus(id) |
{ id, name, target, state, sentBytes, totalBytes, sentFrames, totalFrames, progress, bytesPerSecond, elapsedMs, reason? }; state is sending, done, failed (player_left) or cancelled. A finished stream answers for a minute, then nil, not_found. |
Open77.net.cancelLatent(id) |
stops a stream still sending; false, already_finished otherwise. The client drops a partial copy on its own after a minute without a frame. |
| Bounds | 16 streams in flight per resource (latent_stream_limit); a payload over 4 MiB is refused before anything is sent (latent_payload_too_large); the argument rules of TriggerClientEvent apply (latent_payload_not_serializable). |
Permission summary
| Capability | Server namespaces |
|---|---|
network.events |
Open77.net, RegisterNetEvent, TriggerClientEvent |
world.loot |
Open77.loot |
world.vehicles |
Open77.vehicles |
world.npcs |
Open77.npcs |
world.elevators |
Open77.elevators |
world.props |
Open77.props |
players.appearance.relay |
Open77.appearance.capture / Open77.appearance.apply β reads and writes the face and body a player wears. Separate from player.appearance.edit (client-side, local player) and from clothing: a snapshot is character-creator output, so a reader can fingerprint every player and a writer can put a face on somebody who did not choose it |
world.clearArea.foreign |
Open77.world.clearArea with foreign = true β lets a sweep remove other resources' vehicles. Grant it only to a resource that is trusted to own the whole world, such as an event-setup script; without it clearArea still works on everything the caller owns |
world.effects |
Open77.effects, including the visual half of Open77.effects.explosion |
world.explosions |
Open77.effects.explosion with a positive damage β area damage to every player in a radius, including players the resource never named. Deliberately not players.stats.apply, which only reaches a player already named one at a time |
world.environment |
Open77.environment: the session's clock and sky, and the per-bucket overrides β the same capability name the client side of this API uses |
players.life.read |
Player life reads |
players.life.kill |
Open77.players.kill |
players.life.revive |
Open77.players.revive |
players.life.respawn |
Open77.players.respawn |
players.teleport |
Open77.players.teleport / setHeading β deliberately not under players.life.*: a move is not a life event and has a different blast radius |
players.life.resync |
Open77.players.requestLifeResync |
players.life.freeze |
Open77.players.setFrozen |
players.damage.read |
Open77.players.getHealth |
players.damage.apply |
Player health/damage mutations |
players.stats.read |
Shared Open77.stats reads for health and stamina |
players.stats.apply |
Server-only health/stamina values, maximums and regeneration |
players.gate |
onPlayerConnecting handlers: hold, admit or refuse a connecting player |
players.access |
Open77.access: the server's built-in whitelist and ban list |
acl.read |
Open77.acl.isAllowed / roles / grants: read the compiled ACL for an admitted session |
acl.grant:<pattern> |
Open77.acl.grant / revoke / addRole / removeRole, bounded to permissions matching <pattern>. There is no bare acl.grant, and acl.grant:* is not a wildcard β it is dropped. The pattern is the delegation an operator sees before installing the resource. See the ACL guide |
acl.define:<pattern> |
Open77.acl.definePermission: define or retune a runtime role whose every permission matches <pattern>. Separate from acl.grant because retuning a role changes the rights of everyone holding it, at once and retroactively |
players.identity.sensitive |
Open77.players.endpoint / GetPlayerEndpoint, and the endpoint field of Open77.players.identifiers. An endpoint is a player's IP address: it identifies a person outside the game and in most of the world it is regulated personal data, so it gets its own capability rather than riding on the ungated Open77.players.identity. Ping is deliberately not behind it β latency is not identity |
runtime.commands |
Open77.runtime.executeCommand with a plain resource's authority: another resource's ordinary command, never a restricted one and never a lifecycle verb |
resources.control |
Open77.resource.start / stop / restart, and raising executeCommand to the authority the in-game operator escalation uses. The admin-panel capability |
runtime.restart |
Open77.runtime.scheduleRestart / cancelRestart / restartStatus. Its own string rather than a corner of resources.control: stopping one resource and stopping the whole server are different powers, and an operator who granted the first did not thereby grant the second |
players.disconnect |
Open77.players.disconnect / kick |
players.ban |
Open77.players.ban |
combat.config |
Global Open77.combat policy and damage arbiters |
combat.scope.control |
Resource-owned Open77.combat.createScope / removeScope |
players.animations.control |
Start, sequence and stop player RP animations owned by this VM |
players.animations.read |
Query a player's authoritative RP playback |
players.wanted |
Open77.players.setWanted / setMaxWanted - point the NCPD at one player |
players.wanted.read |
Open77.players.getWanted - read back what this server decided for a player |
world.prevention |
Open77.world.setPreventionEnabled - turn NCPD dispatch off for a whole bucket, which every player in it lives under. Separate from players.wanted: making somebody a fugitive and abolishing the police are not the same power |
voice.manage |
Open77.voice authoritative topology and policy |
filesystem.read |
Open77.io.read, readJson, exists, list, stat, and the source side of copy |
filesystem.write |
Open77.io.write, writeJson, append, makeDirectory, remove, move, and the destination side of copy |
database.access |
Open77.database / MySQL, readiness (ready, isReady) included |
http.request |
PerformHttpRequest / Open77.http.request, within the server's http.allowedHosts |
http.serve |
Open77.http.listen / unlisten / SetHttpHandler: inbound HTTP under /<resource>/, on the server's opt-in httpHandlers listener. Distinct from http.request on purpose: the network calling a resource is a different power from the resource calling out |
players.identity.history |
The history half of Open77.players.sessionStats (firstSeenUtc, joinCount, totalPlaySeconds) and all of Open77.players.lastSeen: what the server's identity directory remembers about a person across sessions. The live session is ungated |
world.entities.observe |
Receive the generic onEntityCreated / onEntityRemoved feed. An opt-in on top of the per-kind capability, never a substitute for it: the mirror still costs world.props, world.effects or world.loot for those kinds, so it can only ever show a resource what it could already see |
Request only the capabilities a resource actually uses. A manifest permission grants access to a binding; it does not replace validation of player identity, distance, ownership, revision, bucket, or gameplay state.
Reference coverage
Use the API reference for individual signatures. This guide groups server APIs by task and describes permissions, lifecycle and multi-call workflows.
Server world props and effects are replicated. Client-local Open77.vfx and Open77.sfx calls are separate APIs and do not replicate.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
