Networking & access
Connection control: connect events, whitelists and bans
Connect events, admission deferrals, whitelists, bans and disconnect reasons.
On this page16 sections
- Where the gate sits
- onPlayerConnecting(player, deferrals)
- The player table
- Deferrals
- The rules the host enforces
- onPlayerRejected(userId, name, code, message)
- onPlayerDisconnected(playerId, reason)
- The FiveM names
- setKickReason and CancelEvent(), exactly as in FiveM
- Reading an admitted player's identity
- Steam, GOG and permanent account identifiers
- Removing and banning players who are in
- Wall clock
- The built-in door list
- Worked example: a whitelist
- Worked example: a ban list with expiry
- Fail closed
- Operator notes
- Troubleshooting
- API summary
Control server admission with connection events, deferred checks, whitelists and bans. Resources can reject a connection with a player-facing message or remove an active player. See the server Lua API for method signatures.
Where the gate sits
A connection goes through these stages in order. Resources take part in the stages in bold.
- The transport connects. Nothing is known about the player.
- The client sends its hello: protocol, game build, display name, identity public key with a proof, and a Master connect ticket.
- The server checks the platform facts: protocol and build, identity proof against its Master
key, signed game and DLC ownership in the connect ticket, identity key stability, capacity. A failure
here is a refusal resources are told about (
onPlayerRejected) but cannot influence. - The server's own door list,
access.jsonnext toserver.jsonc: a ban, or a closed whitelist the identity is not on, refuses here with the list's sentence. Warden, the console andOpen77.accessall edit the same list. See The built-in door list. onPlayerConnecting, the resource gate. Every running resource that holds theplayers.gatepermission and registered a handler is asked. Refuse, hold, or let through.- The player is admitted: welcome packet, player id,
playerJoiningand thenonPlayerConnected(playerId). - The join-time readiness gate (
Open77.ready) andonPlayerReady. That gate decides when a resource may act on an admitted player; this page is about whether they get in. See Join-time readiness gate. - The session ends:
onPlayerDisconnected(playerId, reason)andplayerDropped(reason).
The gate lives in Lua rather than in configuration because the questions it answers belong to the server's operator: who is on the list tonight, is this account banned until Sunday, is this slot reserved. The host owns the mechanics (the deadline, combining several resources' answers, what the player sees) so a resource only has to say yes or no.
onPlayerConnecting(player, deferrals)
-- open77.lua
permissions { "players.gate" }
-- server/main.lua
AddEventHandler("onPlayerConnecting", function(player, deferrals)
if player.name == "Mallory" then
deferrals.done("You are not welcome here, Mallory.")
end
end)
A handler that returns without deferring accepts the player. deferrals.done("message") refuses:
the connection is closed and the shell shows exactly that message as the reason.
The player table
The player has no player id yet, so Open77.players.* cannot be used on them. What is known:
| Field | Type | Meaning |
|---|---|---|
userId |
string | The account id the Master issued, a GUID. Stable across renames and reinstalls of the same identity file: the key to use for a whitelist or a ban list. Verified by the identity proof before the gate runs. |
name |
string | The display name the client presents. The player can change it, so match on it for convenience, never for security. |
publicKey |
string | The identity public key, base64. |
fingerprint |
string | sha256:<hex> of the public key. The client console prints the same string for identity.dump, so a player can read it to an operator over voice. |
ticket |
boolean | Whether a Master connect ticket was presented and verified. |
Deferrals
| Call | Effect |
|---|---|
deferrals.defer() |
Hold the player. For an answer that is not immediate: a database row, an HTTP call, a file. |
deferrals.update(message) |
Note progress. Written to the server log as connecting '<name>': <message>. The handshake has no progress channel, so the player does not see it. |
deferrals.done() |
Release your hold. When every handler in every participating resource has released, the player is admitted. |
deferrals.done(message) |
Refuse. The player is disconnected with message on their screen. One refusal is final, whatever the other resources say. |
message is trimmed to 127 bytes of UTF-8 and stripped of control characters. An empty message
becomes Connection refused by the server.
An asynchronous check against the database:
AddEventHandler("onPlayerConnecting", function(player, deferrals)
deferrals.defer()
deferrals.update("looking up the ban list")
Open77.database.single(
"SELECT reason FROM bans WHERE user_id = ? AND (expires_at IS NULL OR expires_at > NOW())",
{ player.userId },
function(row)
if row then
deferrals.done("Banned: " .. row.reason)
else
deferrals.done()
end
end)
end)
The rules the host enforces
| Rule | Detail |
|---|---|
| Permission | Only resources whose manifest lists players.gate take part. A resource with a handler and no permission is skipped, with one WRN: onPlayerConnecting handler ignored: the manifest lacks the players.gate permission. |
| Timing | Handlers run on the resource's next tick, never inside the network code. |
| Combination | Inside one resource, every handler must accept. Across resources, every participating resource must accept. The first refusal wins and nobody else is waited for. |
| Deadline | simulation.connectGateTimeoutSeconds in server.jsonc, default 8, allowed 0.5 to 9. The client abandons the handshake after 10 seconds, so a longer wait would only trade one error for another. At the deadline the player is refused with connection_gate_timeout. |
| Errors | A handler that throws is logged as onPlayerConnecting handler error: ... and counts as an acceptance, so a broken script cannot lock every player out. Where refusing is the safe default, catch the error yourself: see Fail closed. |
| Stop and reload | A resource that stops while holding players releases them. |
| Meanwhile | Packets the client sends while held are ignored. A player who drops while held is forgotten; a late done() is harmless. |
| Nobody listening | When no permitted resource has a handler the gate costs nothing and the player is admitted on the spot. |
onPlayerRejected(userId, name, code, message)
Emitted into every running resource, no permission needed, for every connection refused once its hello was read, whether by the platform checks or by a gate. Use it for audit logs, rate limits, or telling an admin who keeps knocking.
| Argument | Meaning |
|---|---|
userId, name |
Taken from the hello. Empty strings when the hello itself could not be read. For identity_proof_invalid they are what the client claimed and could not prove. |
code |
The reject reason, lowercased: protocolmismatch, gamebuildmismatch, invalidhello, serverfull, duplicatehello, refused. |
message |
For refused, the gate's own text or connection_gate_timeout. Otherwise a machine token: expected_game_build:<build>, identity_proof_invalid, connect_ticket_required, connect_ticket_invalid, identity_key_changed, server_full, duplicate_client_hello, client_hello_required. |
local knocks = {}
AddEventHandler("onPlayerRejected", function(userId, name, code, message)
if code ~= "refused" then return end
knocks[userId] = (knocks[userId] or 0) + 1
if knocks[userId] == 5 then
print(("%s (%s) has been refused 5 times: %s"):format(name, userId, message))
end
end)
onPlayerDisconnected(playerId, reason)
The event every resource already knew now carries the reason. playerId arrives as a string, like
every host event. reason is connection_closed when the transport dropped or the player quit,
otherwise the text the disconnect was queued with by Open77.players.disconnect, kick or ban.
AddEventHandler("onPlayerDisconnected", function(playerId, reason)
print(("player %s left: %s"):format(playerId, reason))
end)
The FiveM names
Three connection events exist under their FiveM spelling as well, so a resource ported from a FiveM server runs without being rewritten. They are additive: the Open77 events above keep their names, their arguments and their timing, and a resource may listen to either family or both.
| FiveM name | Open77 name | What differs |
|---|---|---|
playerConnecting(name, setKickReason, deferrals) |
onPlayerConnecting(player, deferrals) |
Same gate, same tally, same deferrals. The FiveM form receives only the display name; the Open77 form receives the whole player table. source is not set in either: no player id exists yet. |
playerJoining(oldId) with source |
onPlayerConnected(playerId, playerName) |
source is the new player id. oldId is always "". A resource sees playerJoining before onPlayerConnected. |
playerDropped(reason) with source |
onPlayerDisconnected(playerId, reason) |
source is the player who left, and reason is the same text. Both arrive in the same tick. |
source is the global a network event handler already reads, set the same way and by the same
mechanism, so the two families of events mean the same thing by it:
AddEventHandler("playerDropped", function(reason)
local player = source -- the id, exactly as in a RegisterNetEvent handler
print(("player %s left: %s"):format(tostring(player), tostring(reason)))
end)
setKickReason and CancelEvent(), exactly as in FiveM
setKickReason(message) records a refusal message but does not reject the connection. Call CancelEvent() in the same playerConnecting handler to reject it. Cancellation takes precedence over a pending deferral; without a message, the reason is refused. Recording a reason without cancelling logs a warning for that resource.
-- a ported whitelist keeps its shape
AddEventHandler("playerConnecting", function(name, setKickReason, deferrals)
if not allowed[name] then
setKickReason("You are not on the whitelist.")
CancelEvent()
end
end)
deferrals.done(message) remains the Open77-native refusal and works in the same handler; the two
never disagree, because the first decision wins and the other is answered gate_already_decided.
Reading an admitted player's identity
Open77.players.identity(playerId) (alias GetPlayerIdentity) returns
{ userId, name, publicKey, fingerprint, joinedAt } for an admitted player, or nil. joinedAt
is ISO 8601 UTC. Since 2.31.13+op77.101, the table also includes license, steam and gog
when carried by the verified Master ticket. No permission is needed. The existing userId
identifies the installation's cryptographic identity and remains the key accepted by
Open77.access; use license for account-level persistence across linked devices.
Open77.players.identifier(playerId) and Open77.players.name(playerId) remain the short forms.
Steam, GOG and permanent account identifiers
Server Lua, since 2.31.13+op77.101. Read identifiers in onPlayerConnected, after the
server has admitted the player. No extra permission or store API call is required in a resource.
Epic: server 2.31.15+op77.105 also supports epic, a 32-character lowercase
hexadecimal Epic account ID. The matching Master update is deployed. The server
advertises v3 ticket support on each heartbeat, and Master includes the Epic ID
for a qualified linked account. Older servers continue receiving v2 tickets and
can still admit Epic-linked players, but cannot expose their Epic ID. Updating
those servers is optional; existing clients remain compatible.
AddEventHandler("onPlayerConnected", function(playerId)
local ids, reason = Open77.players.identifiers(playerId)
if not ids then
print("Identity unavailable: " .. tostring(reason))
return
end
local license = ids.license -- permanent Open77 account ID
local steamId = ids.steam -- nil when Steam is not linked
local gogId = ids.gog -- nil when GOG is not linked
local epicId = ids.epic -- nil when Epic is not carried by the ticket
print("Open77 license: " .. tostring(license))
print("Steam: " .. tostring(steamId))
print("GOG: " .. tostring(gogId))
print("Epic: " .. tostring(epicId))
end)
The table fields and GetPlayerIdentifierByType return strings without a type prefix:
| Field / type | Format | Use |
|---|---|---|
license |
Open77 account GUID as 32 lowercase hexadecimal characters, without dashes | Persistent account key for characters, inventories and progression across linked device identities |
steam |
SteamID in lowercase hexadecimal, without 0x |
Linked Steam account; this is not the decimal SteamID64 representation |
gog |
GOG account ID in decimal | Linked GOG account |
epic |
Epic account ID as 32 lowercase hexadecimal characters | Linked Epic account, carried by a verified v3 ticket |
open77 / userId |
The existing identity GUID, with dashes | Installation identity; keep using userId for APIs such as Open77.access that require it |
Keep these values as strings in Lua, JSON and database columns. Do not use tonumber to store
or compare them. playerId / source identifies only the current connection; a display name
can change.
The FiveM-style compatibility functions expose the same values:
-- playerId is an admitted player's session ID.
local license = GetPlayerIdentifierByType(playerId, "license")
local steamId, steamReason = GetPlayerIdentifierByType(playerId, "steam")
local gogId, gogReason = GetPlayerIdentifierByType(playerId, "gog")
local epicId, epicReason = GetPlayerIdentifierByType(playerId, "epic")
-- Array entries include their prefix: license:..., steam:..., gog:..., epic:...
for _, identifier in ipairs(GetPlayerIdentifiers(playerId) or {}) do
print(identifier)
end
GetPlayerIdentifiers also includes the existing open77:, name: and fingerprint: entries.
Missing identifiers are omitted. GetPlayerIdentifierByType accepts type names case-insensitively
and returns nil, "identifier_not_linked" when license, steam, gog or epic is unavailable.
An unsupported type returns nil, "unknown_identifier_type"; a non-string type returns
nil, "invalid_identifier_type". Open77.players.identifiers returns nil, "invalid_player_id"
for an invalid session ID, or nil, "player_not_found" when no player matches it.
The Master verifies Cyberpunk 2077 and Phantom Liberty on the same Steam, GOG or Epic account
once and permanently links that store account to the Open77 account. One qualifying store is
sufficient. Starting with 2.31.13+op77.102, historical proof expiration does not require another
store login or ownership check. The Master still issues a fresh, short-lived signed ticket for
each connection. These identifiers come from the ticket verified by the server; scripts do not
receive raw store tickets, access tokens or passwords. No Discord or Xbox identifier is exposed.
An Open77 administrator can also approve an account manually, for example when its store cannot
be verified automatically. On server runtime 2.31.13+op77.102 or later, this account has a valid
license even when both steam and gog are absent. Resources must handle that case and
should use license for account-level persistence. Missing IDs remain nil in the table and
are omitted from GetPlayerIdentifiers; no placeholder store ID is invented.
Store linking is currently optional. Since launcher/Master 2.31.13+op77.103, players can
choose Skip for now and still receive a signed ticket accepted by server .102 or later.
Their permanent Open77 license remains available, with both store IDs absent if none is
linked. Skipping does not attest ownership or create a manual approval. Existing verified
Steam/GOG IDs remain available. Resources must not require a store ID to identify a player.
Manual approval can be removed without deleting genuine store links; while linking is optional,
its absence does not block connections.
Store IDs identify linked accounts; they do not report current subscription or refund status.
Admission does not continuously monitor already admitted players. The explicit private loopback
devLocalAuth mode has no verified account or store identifiers, including license.
Open77.players.identity(playerId) and GetPlayerIdentity(playerId) expose the same three
ownership fields. Only the separate endpoint field in Open77.players.identifiers needs
players.identity.sensitive.
Removing and banning players who are in
| Function | Permission | Effect |
|---|---|---|
Open77.players.disconnect(playerId, reason?), alias kick |
players.disconnect |
Closes the session. The reason is shown to the player and delivered to onPlayerDisconnected. |
Open77.players.ban(playerId, reason?, durationSeconds?) |
players.ban |
Records a device ban for this server at the Master and disconnects now. Enforced at the identity stage on the next connect, so it holds with no resource running. |
Use the Master ban for hard bans. Use your own list, as below, for anything you want to edit, expire, explain, or share between servers you run. Details and error codes are in Disconnecting and banning a player.
Wall clock
The server sandbox has no os library, and GetGameTimer and Open77.time.monotonic restart with
the process. For a ban that expires on Sunday:
| Function | Result |
|---|---|
Open77.time.unix(), alias GetUnixTime |
Seconds since 1970-01-01 UTC, fractional. |
Open77.time.utc(), alias GetUtcTimestamp |
The same instant as an ISO 8601 string, for logs and files. |
The built-in door list
Most servers need no code for this: the server keeps its own whitelist and ban list in
access.json next to server.jsonc, enforced before the resource gate and with no resource
running. Every entry is keyed on the Master userId and remembers who added it and when.
This is a server-enforced policy, not a launcher filter. It applies to direct connections,
Master-ticket connections and reconnects. A valid ticket or a resource's deferrals.done()
cannot override it: the server checks the verified identity before the resource gate and
again immediately before admission.
Warden: the Whitelist & bans tab (permissions access.view to see, access.edit to change)
switches the whitelist, lists and removes identities, bans with a reason and a duration, lifts bans,
and shows the recent refusals with the sentence each player saw, each with Allow and Ban
buttons. The Players tab gains an Allow button per row, so a guest can be let in without
leaving the table. Bans from this tab disconnect the player at once if they are online; they are
local to this server, unlike Open77.players.ban, which records a device ban at the Master.
Enabling the whitelist immediately disconnects all unlisted players, including connections still waiting for a Lua gate. Removing an identity while the whitelist is enabled does the same for every session using that identity. Staff and the operator's own game account have no automatic exemption: add those game identities before enabling it. Being logged into Warden is not itself permission to join the game. The refusal explains that the server is private and includes the identity to give to an administrator.
The same policy is checked before processing further player packets and on the server tick, so a quiet client or a change made through Lua cannot retain gameplay authority. Lua changes take effect no later than the next tick; Warden and built-in console changes enforce it before returning. Players still listed remain connected.
Changes are atomically saved before success is reported. If saving fails, the previous policy
remains in force and the caller receives an error. An existing unreadable or malformed
access.json now stops server startup instead of silently disabling the whitelist; repair
or restore that file. A genuinely missing file still means a fresh server with the whitelist
disabled. Use Warden, the console or Open77.access for live edits; manual disk edits are read
at startup. Each server should have its own configuration directory, since that directory
also owns its access.json.
Console (also from Warden's live console):
whitelist state and listed identities
whitelist.on | whitelist.off
whitelist.add <userId> [label]
whitelist.remove <userId>
bans active bans
ban.id <userId> [seconds|0] [reason]
unban <userId>
Lua, with the manifest permission players.access, through Open77.access:
| Function | Result |
|---|---|
status() |
{ whitelistEnabled, whitelist = { { userId, label, addedAt, addedBy } }, bans = { { userId, name, reason, bannedAt, expiresAt?, bannedBy } } } |
setWhitelist(enabled) |
true |
allow(userId, label?), disallow(userId) |
true; disallow returns false when the identity was not listed |
isAllowed(userId), isBanned(userId) |
boolean |
ban(userId, reason?, seconds?, name?) |
true; nil or 0 seconds is permanent; disconnects the player if online |
unban(userId) |
true, or false when there was no ban |
Every call returns false, reason on failure: permission_denied:players.access,
invalid_user_id, access_unavailable, access_persistence_failed (whitelist/allow/disallow/unban),
or ban_persistence_failed. A persistence failure leaves the previous policy unchanged.
Entries written from Lua carry resource:<name> as their
author, so Warden shows which script let someone in.
-- A command that bans the speaker's target on the built-in list for a day.
RegisterCommand("dayban", function(_, args)
local identity = Open77.players.identity(tonumber(args[1]) or 0)
if not identity then print("usage: dayban <playerId> [reason]") return end
Open77.access.ban(identity.userId, args[2] or "one day off", 86400, identity.name)
end, true)
The two worked examples below build the same thing by hand, for a server that wants its own rules, its own storage, or its own wording.
Worked example: a whitelist
-- open77.lua
resource "my_whitelist"
version "1.0.0"
auto_start true
server_script "server/main.lua"
permissions { "players.gate", "filesystem.read", "filesystem.write" }
-- server/main.lua
local FILE = "whitelist.json" -- lives in this resource's data/ folder
local list = Open77.io.readJson(FILE) or { users = {} }
local function save()
local ok, err = Open77.io.writeJson(FILE, list)
if not ok then print("whitelist: cannot save: " .. tostring(err)) end
end
AddEventHandler("onPlayerConnecting", function(player, deferrals)
if list.users[player.userId] then return end -- on the list: accepted
deferrals.done(("This server is private. Ask an admin to add %s."):format(player.userId))
end)
RegisterCommand("whitelist.add", function(_, args)
local id, label = args[1], args[2] or "?"
if not id then print("usage: whitelist.add <userId> [label]") return end
list.users[id] = label
save()
print(("whitelist: added %s (%s)"):format(id, label))
end, true)
RegisterCommand("whitelist.remove", function(_, args)
if args[1] and list.users[args[1]] then
list.users[args[1]] = nil
save()
print("whitelist: removed " .. args[1])
end
end, true)
The true after each command keeps it restricted to the console and to ACL-listed admins. A
player finds their own id with identity.dump in the client console; an admin also reads it from
the refusal line the server logs, or from onPlayerRejected.
Worked example: a ban list with expiry
permissions { "players.gate", "players.disconnect", "filesystem.read", "filesystem.write" }
local FILE = "bans.json"
local bans = Open77.io.readJson(FILE) or {} -- [userId] = { name, reason, expires }; expires 0 = permanent
local function save() Open77.io.writeJson(FILE, bans) end
local function activeBan(userId)
local ban = bans[userId]
if not ban then return nil end
if ban.expires ~= 0 and ban.expires <= Open77.time.unix() then
bans[userId] = nil -- served its time
save()
return nil
end
return ban
end
AddEventHandler("onPlayerConnecting", function(player, deferrals)
local ban = activeBan(player.userId)
if not ban then return end
local left = "permanently"
if ban.expires ~= 0 then
left = ("for %d more minutes"):format(math.ceil((ban.expires - Open77.time.unix()) / 60))
end
deferrals.done(("Banned %s: %s"):format(left, ban.reason))
end)
-- ban <playerId> [minutes|0] [reason...]
RegisterCommand("ban", function(_, args)
local playerId = tonumber(args[1])
local identity = playerId and Open77.players.identity(playerId)
if not identity then print("usage: ban <playerId> [minutes|0] [reason]") return end
local minutes = tonumber(args[2]) or 0
local reason = args.n >= 3 and table.concat(args, " ", 3, args.n) or "banned by an admin"
bans[identity.userId] = {
name = identity.name, reason = reason,
expires = minutes > 0 and (Open77.time.unix() + minutes * 60) or 0,
}
save()
Open77.players.disconnect(playerId, "Banned: " .. reason)
end, true)
RegisterCommand("unban", function(_, args)
if args[1] and bans[args[1]] then
bans[args[1]] = nil
save()
print("unbanned " .. args[1])
end
end, true)
The shipped resource resources/dev/open77_gatekeeper combines both examples with a whitelist
switch, a gate.list command and audit lines from onPlayerRejected and onPlayerDisconnected.
It does not start by itself: ensure open77_gatekeeper at the server console.
Fail closed
The host admits a player whose handler crashed, because a typo in one script must not empty the server. A gate whose safe default is to refuse wraps its own check:
local function check(player)
-- return nil to admit, or the sentence the player should read
if not list.users[player.userId] then return "This server is private." end
end
AddEventHandler("onPlayerConnecting", function(player, deferrals)
deferrals.defer()
local ok, verdict = pcall(check, player)
if not ok then
print("gate error: " .. tostring(verdict))
deferrals.done("The server cannot verify your access right now. Try again in a minute.")
else
deferrals.done(verdict) -- nil admits, a string refuses
end
end)
Operator notes
server.jsonc:"simulation": { "connectGateTimeoutSeconds": 8 }.- Every refusal is logged at
INFasPlayer '<name>' (<userId>) refused (<Reason>): <message>. Progress notes appear asconnecting '<name>': <message>under the resource that wrote them. - Key lists on
userId. A display name is chosen by the player. - Keep the list in the resource's
data/folder throughOpen77.io, or in the database throughOpen77.database; both surviveensureand restarts. A Lua table alone does not. - The gate runs on every connect, including reconnects after a crash, so keep the check cheap or deferred.
Troubleshooting
| Symptom | Cause |
|---|---|
| The handler never runs | The manifest lacks players.gate (look for the WRN), the resource is not running, or the handler was registered with RegisterNetEvent instead of AddEventHandler. |
Everyone is refused with connection_gate_timeout |
A code path calls defer() and never reaches done(), for example a database callback that never fires. Every branch after defer() must end in done. |
The player sees Connection refused by the server. |
done() was given an empty string or a non-string value. |
| The message is cut short | 127 bytes of UTF-8. |
| A ban by name stopped working | The player renamed themselves. Ban by userId. |
API summary
| Name | Permission | Signature |
|---|---|---|
onPlayerConnecting (event) |
players.gate |
(player, deferrals) |
deferrals.defer / update / done |
via the event | () / (message) / (message?) |
onPlayerRejected (event) |
None | (userId, name, code, message) |
onPlayerConnected (event) |
None | (playerId, playerName) |
onPlayerDisconnected (event) |
None | (playerId, reason) |
playerConnecting (event) |
players.gate |
(name, setKickReason, deferrals) |
playerJoining (event) |
None | (oldId), source = the new player id |
playerDropped (event) |
None | (reason), source = the player who left |
Open77.players.identity / GetPlayerIdentity |
None | (playerId) -> { userId, name, publicKey, fingerprint, joinedAt, license?, steam?, gog?, epic? } or nil |
Open77.players.identifiers |
None for account/store IDs | (playerId) -> { open77, userId, name, fingerprint, joinedAt, license?, steam?, gog?, epic?, endpoint? } or nil, reason; endpoint needs players.identity.sensitive |
GetPlayerIdentifierByType |
None | (playerId, type) -> bare string or nil, reason |
GetPlayerIdentifiers |
None | (playerId) -> array of "type:value" strings or nil, "player_not_found" |
Open77.players.identifier / name |
None | (playerId) |
Open77.players.disconnect / kick |
players.disconnect |
(playerId, reason?) |
Open77.players.ban |
players.ban |
(playerId, reason?, durationSeconds?) |
Open77.access.status / setWhitelist / allow / disallow / isAllowed / isBanned / ban / unban |
players.access |
the built-in door list, see above |
Open77.time.unix / GetUnixTime |
None | () -> seconds |
Open77.time.utc / GetUtcTimestamp |
None | () -> ISO 8601 string |
simulation.connectGateTimeoutSeconds (config) |
default 8, range 0.5 to 9 |
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
