# OPEN//77: complete documentation > OPEN//77 is an open, community-run multiplayer platform for Cyberpunk 2077. It is not a single server: it is the client, dedicated server and scripting layer that lets communities host and script their own persistent Night City worlds. This site documents the platform and its Lua API, and hosts the live server browser. Everyone with Alpha access can download the Windows or Linux server and build a custom gamemode without a separate developer application. To request Alpha access, use /alpha apply in any channel on the official Discord. APIs and features may change. The project is unaffiliated with CD PROJEKT RED. Source: https://open2077.net/docs Everyone with Alpha access can download the server and start building. No separate developer application is required. Need Alpha access? Use /alpha apply in any channel on our Discord. Expect bugs and API changes during Alpha. The Windows launcher is available at https://open2077.net/download. Everyone with Alpha access can download Windows/Linux dedicated servers at https://open2077.net/host. The server packages include Freeroam and its system resources. The directory reads the live master; explicitly labelled demo views remain illustrative only. Downloading or registering an account does not grant access to join. Read the scope and known limitations at https://open2077.net/docs/alpha-access. APIs and supported features can change. OPEN//77 is an unofficial, independent community project. It is not affiliated with, endorsed by, or sponsored by CD PROJEKT S.A. “Cyberpunk”, “Cyberpunk 2077” and related marks are trademarks of CD PROJEKT S.A. Game imagery is used for illustration of a fan project. Playing on OPEN//77 will always require your own legal copy of Cyberpunk 2077. The platform does not accept piracy, and pirated or cracked copies are neither supported nor welcome. --- Source: https://open2077.net/docs # Open77 resource documentation Build multiplayer gameplay and interfaces as resources: directories containing a manifest, Lua scripts, declared permissions, dependencies and optional web assets. Developers familiar with FiveM will recognize the client/server split, events, exports, commands, and manifest-driven lifecycle. Open77 APIs remain independent and reflect REDengine constraints. ## Session model [Custom loading screens](loading-screens.md) covers branded WebUI covers, native loading events and HUD hiding. [Changing seats](vehicle-seat-switching.md) covers animated in-cabin moves, automatic driver takeover and Lua controls. New building blocks: [player checks and controls](player-utilities.md) and [package audio, local and networked](package-audio.md). The server selects the resource set for a session. A connecting client downloads that set, verifies its signature and content hashes, and activates it before entering the world. Only Open77's trusted bootstrap resources load outside the server-provided generation. The server is authoritative. Clients render approved state and submit bounded observations or requests; they do not choose canonical loot, life, vehicle, time, weather, or routing state. ## Create a resource A resource lives below the server's configured resource root and contains a `open77.lua` manifest: ```lua resource "hello" version "1.0.0" auto_start true client_script "client/main.lua" server_script "server/main.lua" ``` Client entry point: ```lua AddEventHandler("onClientResourceStart", function(name) if name ~= GetCurrentResourceName() then return end local state = Open77.character.state() print(("spawned at %.1f, %.1f"):format(state.position.x, state.position.y)) end) ``` Server entry point: ```lua RegisterCommand("hello", function(source, args) print(("player %d said hello"):format(source)) end, false) ``` Start the server, connect a client, and invoke `hello` from the Open77 developer console or chat. ## Guides New client utilities: [screen picking](screen-picking.md) and the reusable [ALT/click context menu](context-menu.md). | Guide | Subject | |---|---| | [Hacking and counterplay](hacking.md) | Short Circuit, Self-ICE, active/ally purge, server authority, public definitions and an optional lab. | | [Ground Slam / Quake](ground-slam.md) | Native grounded/airborne slam, server-owned impact validation and permission-controlled session grants. | | [Ability activation leases](ability-activation-leases.md) | Why abilities no longer wait for the server at the moment a key is pressed, what a far-away player will notice, and what an owner will see in the log. | | [Server resources](server-resources.md) | Manifests, runtime separation, signing, download, and reload. | | [Hub resources in Warden](community-hub-warden.md) | Reviewed installation, updates, uninstall, rollback, preload restarts and retained backups. | | [The Players tab in Warden](warden-players.md) | The live roster (ping, health, life state, match), and the per-row admin actions: warn, kick, ban, grant rights, heal, freeze and move — plus why spectate is not adaptable. | | [Mods: the complete guide](mods.md) | The three layers (stack, world, own), the life of a world mod from server to game folder, declaring packages, a Nexus vehicle walkthrough, verification and troubleshooting. | | [Mods your server requires](server-mods.md) | The operator's reference for the same feature: trust, hosting the bytes, the master's hash vouching, Warden and review. | | [Sky hologram advertisements](sky-advertising.md) | Build PNG/DDS art into Towers-of-Light XBM replacers and preload it from a resource. | | [Complete server Lua API](server-api.md) | Every server global, `Open77.*` method, permission, constant, and low-level alias. | | [FiveM compatibility aliases](fivem-compatibility.md) | The `Citizen` table, `SetTick`, `promise.new`, and the shared-script helpers a copied FiveM resource calls -- plus the three places Open77 deliberately answers differently. | | [Dash / Air Dash](dash.md) | Native ground and air dash as a session grant: define, grant, the key, charges, stamina, cooldown and the optional parkour example. | | [Connection control](connection-control.md) | Connect events and deferrals, refusing a player with a message, rejection and disconnect reasons, the built-in whitelist and ban list (Warden, console, `Open77.access`), and worked custom resources. | | [World queries](world-queries.md) | Client raycast, aim ray, ground height and the object search around the player (`world.query`), plus an entity's own axes, offsets and slots (`world.transform`). | | [Replicated state bags](state-bags.md) | `Open77.state.global` / `.player(id)` / `.entity(kind, id)` and the `Player(id).state` / `Entity(id).state` / `GlobalState` / `LocalPlayer.state` shims: keyed state that replicates itself, change handlers, the caps, and why a client submits instead of writing. | | [Players around you (client)](client-players.md) | Client enumeration: who this client can see, the player id <-> body mapping, proximity and closest -- and why this roster is not the server's. | | [Travel](travel.md) | Moving the local player and knowing when the body has really arrived: the settle watch, why a plain teleport can silently land somebody kilometres away, noclip and the map pick. | | [Vectors and quaternions](vectors.md) | `vector2`/`vector3`/`vector4`/`vec`/`quat` on both runtimes: arithmetic, `#(a - b)`, swizzles, distance and lerp, the "plain tables still work" guarantee, and what crosses the network. | | [Official resource exports](resource-exports.md) | Every client export exposed by the official Lua packages and how to call it safely. | | [Cross-resource server exports](server-exports.md) | Publish and call asynchronous server services with caller identity, isolated data, permissions and safe reload behavior. | | [Lua modules and require](lua-modules.md) | Local modules, published dependency libraries, per-resource cache, permissions, reload and differences from exports. | | [Network callbacks](callbacks.md) | Ask the other side a question and await the answer: `Open77.net.call` / `callClient`, per-resource namespacing, timeouts, limits, failure reasons, and why a callback is still only a request. | | [Game data reference](data-reference.md) | NPC templates, vehicle records, seats, flags, weapons, appearances, VFX, SFX, animations, and sprite catalogues. | | [Identity and ACL](server-acl.md) | Authentication, restricted commands, and access control. | | [Player identity](identity.md) | Durable identifiers, display names, and rename flow. | | [Equipment Lua API](../docs/equipment.md) | Validated wardrobe catalogue, local/server APIs, permissions, errors, and appearance replication. | | [Perspective](../docs/perspective.md) | First and third person: the ownership arbiter, the server policy, the player's key and persisted preference. | | [Photo mode](photo-mode.md) | Exclusive client-resource control of the native photo mode; stock shortcuts are disabled. | | [Screenshots and mugshots](screenshots.md) | In-process captures of the game's own frame (`screen.capture`), the upload to a URL (`screen.upload`), the server-requested picture delivered by the client (`players.requestScreenshot`), and the scripted-camera mugshot (`screen.mugshot`): what is captured, the surface formats, the in-flight rule, and where the picture goes. | | [Weapon Lua API](weapons-api.md) | Assign standard weapons by TweakDB template, select slots, holster, clear every slot, snapshot, and read the server's synchronous weapon cache. | | [Game data catalogues](data-catalogues.md) | `Open77.data.vehicle/weapon/item/npc/localize` on both runtimes: display names, classes, seats and qualities from a record string -- live TweakDB on the client, a shipped catalogue on the server, and the measurement behind that split. | | [Player health and stamina](player-stats.md) | Shared client/server reads, server-only setters, maximums, regeneration, and synchronization semantics. | | [Freezing a player](player-freeze.md) | Server-authoritative and client-local holds on the player's body: the engine restriction used, exactly what a freeze does and does not stop, cooperative claims, and every release path. | | [Blue holocall eyes](holocall-eyes.md) | Native blue eye glow, server-owned Lua leases, duration, lifecycle and multiplayer presentation. | | [Cyberware framework](cyberware.md) | Persistent implants, permissions, transactions, temporary loadouts and native projection. | | [Gorilla Arms](gorilla-arms.md) | Build native arm grades, self-service installation, charge effects, clinics and practice arenas. | | [Loot](loot.md) | Authoritative ground drops and pickup integration. | | [Weather](weather.md) | Session time, weather presets, synchronization, and events. | | [Time scale](world-time.md) | Slow motion for a scripted beat: `Open77.world.setTimeScale` on the client as a claim the dilation guard stands down for, and per routing bucket from the server; measuring it against the world clock, every release path, and why it is presentation and not a Sandevistan. | | [Vehicles](vehicles.md) | Identity, streaming, authority leases, seats, and Lua APIs. | | [Vehicle AI](vehicle-ai.md) | Autonomous driving, routes, entity following, NPC drivers and authoritative task events. | | [Native map](native-map.md) | Player waypoints, map lifecycle, marker selection and non-travel point picking. | | [Armed vehicle spawn catalogue](armed-vehicles.md) | Exact spawn IDs, mounted-weapon profiles, verified examples, and the complete 2.31 record inventory. | | [Vehicle weapon Lua API](vehicle-weapons.md) | Client armament queries: mounted/selected weapons, native ammo, heat, trigger mode and turret aim. | | [Passenger drive-by](drive-by.md) | Client/server Lua policy, action phases, permissions, cleanup, synchronized windows and protection of the shooter's own cabin. | | [Vehicle paint](vehicle-paint.md) | Server-authoritative RGB paint, cross-resource controls, replication, events, and native limitations. | | [NPCs](npcs.md) | Implemented server-owned NPCs, templates, streaming, task queues, authority leases, life, events, and Lua APIs. | | [NPC AI, combat and voice](npc-behavior.md) | Server control of one spawned NPC's behavior policy: AI, combat, perception and voice, and what it deliberately does not reach. | | [NPC record catalogue](npc-catalogue.md) | The 6,582 `Character.*` records of 2.31, searchable. A discovery index, not a spawn allowlist. | | [Contextual interactions](interactions.md) | Custom world/NPC prompts, action keys, projection, ownership, and server-safe integration. | | [Synchronized attachments](attachments.md) | Props on player bones or vehicles, local transforms, ownership, streaming and lifecycle cleanup. | | [Player interactions](player-interactions.md) | Reserved two-player actions, consent, synchronized presentation and server-owned completion. | | [Key mappings and device input](keybindings.md) | The `RegisterKeyMapping` engine primitive: named rebindable actions, press/hold callbacks, the pause KEY BINDINGS tab, and machine-global persistence -- plus reading the mouse, the wheel, a gamepad and the cursor directly. | | [Proximity zones](zones.md) | Client-side enter/exit hysteresis and the server-side re-validation every caller must apply. | | [PolyZone](polyzone.md) | Polygons, boxes, circles/spheres, entity zones, groups, entry/exit callbacks and a native-debug/WebUI zone editor. | | [World-anchored POIs](worldui.md) | The marker-plus-prompt facade: one owned handle, transactional creation and cleanup. | | [The gamemode kernel](gamemode-kernel.md) | Shared server services through exports, local state-machine conventions, and why the scaffolder remains useful. | | [Deathmatch](deathmatch.md) | The shipped free-for-all and arena mode: instances, loadouts, bots, scoring and the operator surface. | | [Cordon](cordon.md) | The 64-player battle royale: the closing block cordon, squads and revive at the body, ground loot, bots as contestants, the tunables and what is measured versus designed. | | [The UI kit](ui-kit.md) | Progress bars, text hints, alerts, input forms, context menus, keyboard menus and radials: the promise shape, the cancellation contract, the focus release matrix, the styling tokens a resource may override, the server twins -- and asking whether any menu, Open77's or the game's, is on screen. | | [Notifications](notifications.md) | Reusable WebUI toasts, client/server exports, queues, positions, progress, and ownership. | | [Shared WebUI assets](webui-assets.md) | Share images, CSS, modules, fonts and data between resources through `open77://`, with manifests, examples, lifecycle behavior and troubleshooting. | | [Elevators](elevators.md) | Implemented server-authoritative native lifts, bucket/chunk streaming, late join, ACL commands, and Lua APIs. | | [Networked doors](doors.md) | Dynamic discovery, automatic and elevator landing doors, server authority, access rules, and Lua exports. | | [Blips](blips.md) | Vanilla map markers, entity attachment, sprites, and the range gate. | | [Vanilla device prompts](device-interactions.md) | Turning a vending machine's, ATM's or terminal's own prompt off so a resource can put its own there: the one scripted choke point every device passes through, why `GetActions` is the wrong gate, naming a device by id or by class, the ownership rule, `open77:deviceUsed`, and the server-declared policy. | | [Blips](blips.md) | Vanilla map markers, entity attachment, and sprites. | | [Vanilla HUD visibility](hud-visibility.md) | Client API for minimap, compass, clock, health, stamina, weapons/ammo, speed, and custom-HUD replacement. | | [Blocking player input](input-blocking.md) | Taking a named input away from the player and giving it back: the curated vocabulary, the five mechanisms behind it, what this engine cannot block and why, per-resource ownership and the release rule. | | [Scripted cameras](cameras.md) | Put the view where a resource wants it: world position, look-at, attachment, blended activation, shake and `unproject` -- plus the ownership rule, every path that gives the view back, and exactly what this engine will not give a camera. | | [Third person](perspective.md) | The playable third-person view: the player's key, server policy, the states that hand the view back, reading the player's aim and what it is on, and the measured limitations. | | [RP animations](rp-animations.md) | Synchronized actions, forced local TPP, client/server Lua APIs, commands, ownership and cancellation. | | [RP animation catalogue](rp-animation-catalogue.md) | All 12 profiles and 70 selectable clips, prop/placement requirements and validation status. | | [The role-play kit](rp-kit.md) | `open77_rp_basics`: cuff, escort and search as server exports, the leash an escort really is, the authority that stops a griefer, the twelve ways a hold ends -- and why carry refuses on this build. | | [World props](props.md) | Server-authoritative props and lights: models, streaming, buckets, ownership, and the client projection. | | [Jobs and world interaction](world-interaction.md) | Choose and enable job resources, customize the salvage and street-food examples, and understand which world interactions are supported. | | [Build your first Lua job](build-a-job.md) | A repair activity from files and configuration to gameplay: a work spot, an animation, a required part, payment and cancellation. | | [Actions and carrying](actions.md) | `open77_actions`: put a prompt on a spot, play an animation for a set time, check the player at the start and the end, pay through hooks, keep one player per spot, gate by job and duty, and carry crates into trucks. | | [Items, payments and storage](transactions.md) | Connect inventory and economy code, save related transfers together, resolve payment retries and use persistent world containers. | | [Furniture: sitting and leaning](furniture.md) | Enable seating on supported city furniture, check a venue's seats and approaches, and use seating from Lua. | | [Visual and audio effects](effects.md) | Client-local world/entity VFX and spatialised SFX, the server-replicated effect registry, explosions and script fires. | | [Resource audio](sound.md) | Playing an audio file your resource ships: 2D and world-spatial with HRTF falloff, the client exports and server twins, the caps and lifetime rules, and the honest list of what it is not. | | [Native screen transitions](screen-transitions.md) | Client Lua fades to black/color, native completion events, resource ownership, cancellation and recovery. | | [Privileged debug runtime](debug-runtime.md) | ACL-targeted client Lua execution, native lab commands, and REDscript bridge probes. | | [Autonomous agent testing](agent-testing.md) | The MCP server and skill that let an AI agent drive the real game: stack, connection, tools, scenarios, and safety rules. | | [Chat](chat.md) | Messages, slash commands, completion, and resource integration. | | [Clipboard](clipboard.md) | Write-only client clipboard API and the `/pos` and `/rot` transform commands. | | [Client persistent KVP](client-kvp.md) | Endpoint- and resource-isolated local key/value persistence, search, atomic operations, and quotas. | | [Integrated voice chat](voice.md) | Native Opus VOIP, proximity/radio/phone channels, Lua APIs, pause settings, lipsync state, and speaker nameplates. | | [Voice lipsync](voice-lipsync.md) | Voice-driven male/female mouths, local/F7 and remote players, client Lua controls, resource ownership and diagnostics. | ## API reference coverage Open77 has three deliberately separate Lua surfaces. The wiki covers all three without implying that a server method exists in a client VM or that a package export is a native: | Surface | Reference | Coverage | |---|---|---| | Client native runtime | The **CLIENT** API reference cards in `index.html` | 443 functions across 59 namespaces, host registrations and the globals the client's Lua preludes define alike; every signature is reviewed and every card has a detailed description. | | Dedicated server runtime | The **SERVER** cards in `index.html` and [Complete server Lua API](server-api.md) | 413 functions across 43 namespaces, read from the Lua prelude the server executes: the 64 authoritative vehicle methods and 12 vehicle-AI methods as individually reviewed cards, plus every other `Open77.*` namespace, the `Open77.Promise` and `Open77.EventVerdict` handle types, and the scheduler, event and JSON globals. The guide adds the narrative, the permission tables and the 119 low-level host globals. | | Official client packages | [Official resource exports](resource-exports.md) | 129 of the 151 literal client exports the official resource tree publishes, across 25 of its 30 packages. The automated gate reaches the packages directly below `resources/`; the nested trees are not yet gated. | Generated cards are separated by runtime, so identical names such as `Open77.vehicles.get` cannot confuse a client projection with server authority. They state whether a call is shared, needs a live game instance, or uses the network backend, and document permissions, ownership, generation lifetime, and failure values. Run both documentation checks after changing a binding or export: ```powershell python wiki/tools/extract-api.py --json python wiki/tools/audit-api.py ``` The generator fails when a registered client function has no verifiable handler body or detailed description. It also compares the 64 server vehicle cards with the real embedded Lua bootstrap, so adding or removing a method cannot silently leave the searchable reference incomplete. Both runtimes are read twice over: the host registrations, and the Lua preludes each runtime executes. A global a prelude defines -- `vector3`, `TriggerLocalEvent`, `Open77.net.callAwait` -- is therefore a card like any other, and cannot go missing without the build saying so. ## API conventions ### Engine IDs are opaque REDengine identifiers are 64-bit values and may not be exactly representable as Lua numbers. Store, compare, and return them unchanged. Do not pass them through `tonumber`. ### Failures are values Most APIs return `value` on success or `nil, reason` on failure. Callers can distinguish an invalid request from a temporarily unavailable subsystem without wrapping every call in `pcall`. ### Permissions are explicit Guarded APIs require manifest permissions: ```lua permissions { "network.events", "world.loot" } ``` Request only the capabilities the resource needs. ### Client and server runtimes are separate `server_script` files never reach a player's machine. `client_script` files are distributed in the signed resource set, and `shared_script` files execute in both runtimes. Secrets and authoritative decisions belong exclusively in server code. ## License Open77-owned documentation and code follow the repository [license](../LICENSE). Third-party names, game data, and dependencies remain subject to their respective terms. --- Source: https://open2077.net/docs/alpha-access # Alpha access: play, host and build Everyone with Alpha access can download the OPEN//77 dedicated server and start building a custom Cyberpunk 2077 gamemode. Roleplay, PvP, Battle Royale, Racing, Survival or something entirely your own: use the Lua APIs and resources to create your experience in Night City. No separate developer application, project review or special developer access is required. Alpha remains an early-access release, not a stable release or unrestricted public access. Expect bugs, crashes, incomplete features and breaking API changes. ## Get access **Already have Alpha access?** Sign into your [OPEN//77 account](/account), [download the server](/host) and start building. You do not need to apply again. If you do not have Alpha access yet: 1. Join the [official Discord](https://discord.open2077.net). 2. Use the bot command `/alpha apply` in any channel and follow its instructions. 3. Once Alpha access is enabled on your account, use that account on the site and in the launcher. Creating an account or downloading the launcher alone does not grant Alpha access. Alpha members also have access to the Discord changelog channel for release updates. Approval is checked when joining a world. The live directory and launcher download are available before approval; `/host` enables server downloads for Alpha accounts and staff. A server license identifies a hosted server; it does not grant player access to the account using it. Individual servers can also enforce their own admission rules. ## Install and connect Use the [official launcher download](/download). Playing requires your own legal copy of **Cyberpunk 2077 2.31 with Phantom Liberty**, on 64-bit Windows 10 or 11. Let the launcher verify your installation and apply the current multiplayer update. Select a world in the [live server directory](/servers), or use **Direct Connect** in the launcher with a hostname/IP and port. **History** keeps confirmed connections, including local development servers. Local/private addresses are hidden from the launcher's normal server list, not from History or Direct Connect. Direct Connect does not bypass account approval, connection tickets or server rules. An address absent from the master directory can have an unknown availability status; that is not proof that the server is offline. See [the launcher guide](/docs/launcher) for installation, updates and diagnostics. ## Host your first server Download the current [Windows x64 or Linux x64 server package](/host). Both include their .NET runtime, native dependencies, the Freeroam template and its system resources. Cyberpunk 2077 and a separate .NET installation are not required on the host. 1. Extract into a new directory. Run `Start.cmd` on Windows or `./start.sh` on Linux. 2. Complete the first-run Warden setup with your server name, visibility, public game/resource endpoints and a [server license key](/account/keys). 3. Follow the [hosting guide](/docs/host-a-server) for network/firewall configuration. Internet players must be able to reach both the game endpoint and resource downloads. 4. Leave the master URL at **`https://master.open2077.net/`**. The loopback URL printed for Warden is your local administration panel, not the master directory. The included Freeroam combines racing, PvP, wardrobe and an animation menu, with system packages such as chat, voice, weather and administration. You still need to configure your server and access rules; the download does not include a license or anyone else's player data. Use the official master-backed setup during Alpha. The private/offline startup path remains experimental; do not disable authentication to work around setup errors. For Linux service wrappers, use `SIGINT` for a graceful shutdown: `SIGTERM` shutdown of the game loop is a known limitation. ## Build with Lua and WebUI Start with [server resources](/docs/server-resources), then use the [API reference](/docs/api) filtered by server/client runtime and category. Server and client runtimes support resource exports within their own side; cross-resource calls do not turn a client export into a server export. For character actions, read the [RP animation guide](/docs/rp-animations) and its linked client/server API entries. For a working gamemode example, browse the [Freeroam source](https://github.com/Open2077/open77-resources/tree/main/gamemodes/freeroam). ## Alpha expectations - APIs, network protocols and resource formats may change. Back up configuration, persistent data and custom resources before updating. - Check each system's limitations, including supported models, bodies and animations. - Repeated reconnects can leave stale RP animation proxies or a camera hold. Restart the game and report reproduction steps if this occurs. - Capacity depends on hardware, resources and workload; no universal player-count target is guaranteed. - Launcher and client/server builds have separate version numbers. Use compatible releases from the official downloads. ## Report a problem Use the [official Discord](https://discord.open2077.net) with your launcher and client/server versions, operating system, steps to reproduce, expected result and actual result. Mention whether it happens after reconnecting, with a specific body type, animation, vehicle or custom resource. Attach the relevant logs or launcher diagnostics when requested. Review files before sharing: never post license keys, passwords, account tokens or server identity/signing files. For a possible security issue, contact the maintainers privately instead of posting credentials or exploit details in a public channel. --- Source: https://open2077.net/docs/platform # How OPEN//77 works Connect players to community-operated Cyberpunk 2077 servers and build gameplay with Lua resources. ## Overview OPEN//77 is a multiplayer platform for Cyberpunk 2077. Each community operates its own dedicated server, gamemode and rules. ### OPEN//77 is not - One official multiplayer server run by us - A fixed game mode you have to play - A peer-to-peer co-op session mod - A product of CD PROJEKT RED ### OPEN//77 is - The infrastructure that lets anyone run a Cyberpunk 2077 server - A client that discovers and connects you to community servers - A creator toolkit for building custom game modes and systems - Common ground for players, server owners and developers ## How it works (for players) 1. **Own the game**: OPEN//77 requires your own legal copy of Cyberpunk 2077, at build 2.31, with Phantom Liberty DLC. The platform never distributes game content. It builds on the game you bought. Pirated or cracked copies are not supported and not welcome. 2. **Install the client**: The OPEN//77 client runs alongside your installation. Your single-player game, saves and mods stay untouched. 3. **Browse the servers**: Open the server browser, filter by game mode or language, read a server's page, and pick the world you want to live in tonight. 4. **Connect & play**: The client fetches that server's resource set, verifies its signature and content hashes, and drops you into Night City alongside everyone else on that server. ## Requirements Players need a legal copy of Cyberpunk 2077 at the exact supported game build, with Phantom Liberty installed. Newer game builds are not automatically compatible; see the requirements below. ### To play - **Your own legal copy of Cyberpunk 2077**: OPEN//77 never distributes the game or any of its assets. It builds on the installation you already own: bought, not pirated. Cracked copies are not supported and not welcome. - **Game build 2.31**: This exact build, not a minimum. The client hooks the game engine at addresses established for it and refuses to load when they do not match. - **Phantom Liberty DLC**: The expansion ships as EP1, and the client needs it: the world it loads when you connect to a server is an EP1 save. - **Windows 10 or 11, 64-bit**: The client is a native Windows plugin that loads inside the game process. ### To host a server The dedicated server runs independently of the game and does not require game assets. - **The game is not needed**: A dedicated server runs independently of Cyberpunk 2077, REDengine and the client plugin. It never loads game content and never needs a copy installed. - **Windows x64 or Linux x64**: Both official server packages include the .NET runtime and native networking dependencies. No separate .NET installation or copy of the game is needed on the host. - **Your players still need all of the above**: Everyone who connects needs Cyberpunk 2077 2.31 + Phantom Liberty DLC on their own machine. Hosting changes nothing about that. ## Dedicated servers A dedicated server owns shared world state and accepts player connections. OPEN//77 does not use peer-to-peer sessions. - **Persistent worlds**: The world keeps running when you log off. Economies, factions and stories continue. The server remembers. - **Authoritative state**: Positions, inventories, vehicles, loot, time and weather: the server decides what is true, and clients render approved state. That is what makes real economies and fair PvP possible. - **Operated by communities**: Everyone with Alpha access can download the Windows or Linux server, configure their platform license and build their own worlds. No separate developer application is required. Players need Alpha access to join. ## Resources and scripting Gameplay is packaged as resources: self-contained directories with a manifest, Lua scripts, declared permissions, dependencies and optional web interfaces. The server picks the resource set for a session; connecting clients download it, verify its signature and content hashes, and activate it before entering the world. Documented systems: - **Vehicles** (/docs/vehicles): network identity, streaming, authority leases, seats, doors and damage - **NPCs** (/docs/npcs): server-owned templates, streaming, task queues and life state - **Loot** (/docs/loot): authoritative ground drops and validated pickups - **Time and weather** (/docs/weather): synchronised session time and weather presets - **Elevators** (/docs/elevators): server-authoritative native lifts with late-join catch-up - **Interactions** (/docs/interactions): contextual world and NPC prompts with action keys - **Custom UI** (/docs/notifications): WebUI pages, toasts, blips and map pins - **Identity and ACL** (/docs/server-acl): durable player ids, whitelists and restricted commands Resource manifest (`resources/hello/open77.lua`): ```lua resource "hello" version "1.0.0" auto_start true client_script "client/main.lua" server_script "server/main.lua" permissions { "network.events", "world.loot" } ``` Server entry point (`resources/hello/server/main.lua`): ```lua RegisterCommand("hello", function(source, args) print(("player %d said hello"):format(source)) end, false) ``` Every registered function is listed in the Lua API reference at /docs/api, separated by runtime so a client projection is never mistaken for server authority. APIs can change during Alpha; read each guide's limitations before depending on a feature. ## Roadmap Alpha members can play, download the server and build custom gamemodes now. Development priorities include reliability, compatibility and the resource ecosystem; release dates are not guaranteed. ### NOW (ALPHA): Build and test today Launcher and Windows/Linux server packages, Freeroam, a live server browser, WebUI and documented client/server Lua APIs. Everyone with Alpha access can start building without a separate developer application. ### NEXT: Reliability and developer feedback Broader fresh-install and multiplayer validation, reconnect and animation fixes, clearer diagnostics and improved hosting workflows. ### THEN: Compatibility and capacity Measure representative server workloads and hardware, reduce breaking changes, and document supported combinations as they are validated. ### BEYOND: The ecosystem Featured communities, server reputation, resource sharing between servers: the parts that only matter once real worlds exist. The community will shape these. ## FAQ ### Is OPEN//77 official? Is CD PROJEKT RED involved? No. OPEN//77 is an independent community project. It is not affiliated with, endorsed by, or supported by CD PROJEKT RED. Cyberpunk 2077 is the property of CD PROJEKT S.A. We build alongside the game, not on their behalf. ### Do I need to own Cyberpunk 2077? Yes, always. OPEN//77 never distributes the game or its assets. You need your own legal copy of Cyberpunk 2077 on 64-bit Windows, at game build 2.31, with Phantom Liberty DLC installed. The platform adds multiplayer infrastructure on top of it. ### Does OPEN//77 work with pirated or cracked copies? No. A legal copy of Cyberpunk 2077 and its required expansion is mandatory. Pirated or cracked installations are unsupported. ### Is Phantom Liberty DLC required, or only recommended? Required. The expansion ships as the EP1 content set, and the world the client loads when you connect to a server is an EP1 save. Without the expansion installed there is nothing for it to load. The base game on its own is not enough. ### Do I need Cyberpunk 2077 to host a server? No. The dedicated server runs independently of Cyberpunk 2077 on Windows x64 or Linux x64. Both published packages include the .NET runtime. Everyone who connects still needs their own Cyberpunk 2077 2.31 installation with Phantom Liberty DLC. ### So is this one big multiplayer server? No, and this is the core idea. OPEN//77 is the platform underneath many servers. Communities run their own independent worlds with their own game modes and rules; the client lets you browse and join them. If FiveM's model for GTA V is familiar, that is the shape. ### Can I play it right now? Yes, with Alpha access. Download the launcher and sign in with your approved OPEN//77 account to join a server. Need access? Use the bot command /alpha apply in any channel on our official Discord. Alpha can still have bugs, crashes and incomplete features; this is not a stable release. ### Can I host my own server? Yes. Everyone with Alpha access can download the Windows or Linux server and start developing, with no separate developer application or special access required. The Freeroam template and system resources are included. Follow the hosting guide, configure your license and reachable endpoints, and invite other Alpha players. ### What can server creators actually customize? Servers run resources: packages of server-side and client-side Lua with a manifest, declared permissions and optional web interfaces. The documented systems today include vehicles, NPCs, loot, time and weather, elevators, contextual interactions, chat, notifications, blips and visual effects. A pure racing server and a hardcore roleplay city can both be OPEN//77 servers. ### Will it break my single-player game or saves? The client is designed to run alongside your installation without touching your single-player saves or mod setup. Multiplayer state lives on the server you join. ### How much will it cost? The platform is intended to be free to use. Players must purchase Cyberpunk 2077 and the required expansion; server owners cover their hosting costs. --- Source: https://open2077.net/docs/launcher # The OPEN//77 launcher Use the Windows launcher to locate Cyberpunk 2077, verify compatibility, sign in, install multiplayer files and connect to a server. The launcher checks the game build, required files and account identity before starting a multiplayer session. > **Play and build with Alpha access.** The launcher, multiplayer updates and live > server directory are available now. Everyone with Alpha access can also download > the server without a separate developer application. Need access? Use `/alpha apply` > in any channel on the official Discord. Read the [Alpha guide](/docs/alpha-access). ## What you need | | | |---|---| | **Windows** | The launcher refuses to start anywhere else, and the client is a native Windows plugin | | **Microsoft Edge WebView2 Runtime** | The launcher's window is a web view. It ships with Windows 11 and installs itself on Windows 10 | | **Cyberpunk 2077, bought** | Your own legal copy. OPEN//77 distributes no part of the game | | **The exact game build** | Not a minimum — an equality. See [How the platform works](/docs/platform) | | **Phantom Liberty** | The expansion ships as EP1, and the world you load on connect is an EP1 save | | **An OPEN//77 account** | Created on the website; the launcher signs into it | If WebView2 is somehow missing, the launcher does not fail silently: it puts up a message naming the Microsoft Edge WebView2 Runtime and writes the underlying error to its log. ## Installing it The launcher is published as a **single file**, `Open77Launcher.exe` — roughly 50 MB, with the .NET runtime, the web view assemblies and its own interface assets bundled inside. There is no installer, no setup wizard and no uninstaller: you put the exe somewhere and run it. Everything it later creates lives under `%LOCALAPPDATA%\Open77` and inside your Cyberpunk 2077 folder. ### Windows security warnings The launcher executable is unsigned. Check its Authenticode status with `Get-AuthenticodeSignature .\Open77Launcher.exe` in PowerShell. Windows may show an unknown-publisher warning. Download only from the official OPEN//77 website and verify the file's origin before choosing **More info → Run anyway**. Do not bypass a malware detection. Download integrity checks are separate from publisher signing: the launcher verifies the signed mod index and checks package and self-update hashes. It removes the downloaded-file mark from installed files after hash verification so that Windows does not block required mod libraries. ## Finding your game You are not asked where Cyberpunk 2077 is unless the launcher genuinely cannot work it out. It looks, in this order: 1. An explicit `--game-dir` on the command line. 2. The `OP77_GAME_DIR` environment variable (the process environment, then your user environment). 3. **GOG**, from the registry — `HKLM\SOFTWARE\WOW6432Node\GOG.com\Games\1423049311`. 4. **Steam**, from the registry, and then *every* Steam library: it reads Steam's install path from `HKCU\Software\Valve\Steam` and the `HKLM` install keys, then parses `steamapps\libraryfolders.vdf` to find your other drives. A game on a second SSD is found. If none of that hits, you pick the folder yourself, and the picker is deliberately forgiving: it accepts the install root, `bin\x64`, the executable itself, a Steam library root, `steamapps`, `common`, or the parent of any of those, and falls back to a shallow search under whatever you chose. There is no registry probe for Epic, so an Epic installation is found by picking the folder. Locating files does not verify ownership; see the account-linking step below for supported stores and manual approval. ## Checking the build The launcher computes the **SHA-256 of `bin\x64\Cyberpunk2077.exe`** and compares it with the master's build manifest. If your build is unknown, deprecated or revoked, it warns you. That warning is **advisory**. The launcher will not stop you, because the launcher is not the thing that enforces it — the real check happens when your client asks the platform for a ticket to join a server, and a wrong build is refused there. So a warning at the launcher is not a suggestion you can ignore; it is an early notice of a refusal that will come later, at a much more annoying moment. The practical cause is almost always the same: the game auto-updated. The platform targets one exact build, and a newer patch does not "mostly work" — the plugin compares the byte prologues of the engine functions it hooks and declines to load on a mismatch. ## Signing in Two ways in, both handled entirely in the launcher's own code — the page inside the window never touches the network or your credentials. **Through your browser (the default).** The launcher opens your normal browser on the OPEN//77 website's [launcher authorisation page](/launcher). You sign in there, on the site, exactly as you would to read your account page — **no password is ever typed into the launcher** — and the site hands back a one-time code to a loopback address on your own machine. The code is PKCE-bound (S256) to the launcher instance that started the flow, so a code intercepted in transit is useless to anything else, and the site refuses to redirect anywhere that is not a `127.0.0.1` / `localhost` callback. The launcher then exchanges the code for an account session. If the authorisation page ever tells you the link "points somewhere it shouldn't", that is this guard firing. Close it and start the sign-in from the launcher again. **Directly, with e-mail and password.** The launcher also offers a plain sign-in form that posts your credentials to the platform's login endpoint itself. It is the same account and produces the same session. Either way, the result is written to `launcher-session-v1.dat` under `%LOCALAPPDATA%\Open77`, encrypted with **DPAPI at CurrentUser scope** — so it is readable by your Windows user on that machine and nowhere else, and copying the file to another PC gets you nothing. The game plugin reads it at startup, which is how your account and display name reach the world you join without the game ever handling a password. ## Linking your game once After the multiplayer update, the launcher offers to connect **Steam or GOG** if your Open77 account has not been verified yet. Linking is **optional for now**: choose **Skip for now** to browse and join servers without linking. Your choice is remembered, and you can link later in Settings. When you choose to verify, one store account must own both **Cyberpunk 2077 and Phantom Liberty**. Complete the verification once; your store ID and the result stay linked to your Open77 account. On later launches, you go directly to the server directory without repeating the store sign-in. You can view the linked Steam/GOG accounts in **Settings → Linked stores**. Normal Open77 account sign-in still applies. If your store is not supported yet, use **Skip for now**. Open77 support can also manually approve accounts; this approval is retained for when linking becomes mandatory. Skipping does not create a store ID, mark ownership as verified or automatically grant Alpha access. ## The mod menu The mod menu is where you choose what loads with the game. You reach it from the cube in the title bar, from a **Mods** row in Settings, or as a secondary action on the READY screen. > **It is not switched on for everyone yet.** There are two ways an install can > be kept up to date: the older whole-payload updater, and the mod stack that > everything below describes. Which one runs is decided once per launch, and the > mod stack only takes over where the platform has published a signed mod index. > Until then the menu says so in as many words — *this install is managed by the > classic OPEN//77 updater, and the mod list becomes available once the platform > publishes a mod index* — and there is nothing to choose. The two never both > run: the old updater would silently reinstate every file the new one had > disabled. The rule that shapes everything else: **the page decides nothing.** Every question — what depends on what, what conflicts with what, what may not be switched off — is answered by the launcher itself, and a request the rules do not allow is *refused by name* rather than quietly bouncing back. A checkbox that springs back with no explanation is the single worst thing a mod menu can do, so every refusal carries a reason: | Reason | What it means | |---|---| | `mandatory` | This is part of the floor multiplayer needs. It cannot be off | | `required_by` | Something else that is on needs this — and it names what | | `conflict` | Two packages want the same file, and it names both sides | | `consent_required` | This one changes something you should agree to first, with the points to read | | `needs_file` | This package's archive has to be supplied before it can be enabled | | `game_running` | Cyberpunk 2077 is open. Files cannot be moved under a live game | | `blocked` | A rule refuses this one, with the reason and, when there is one, a link | ### Mandatory and optional Four packages make up the floor and cannot be unticked: | Package | Why it cannot be off | |---|---| | `open77-core` | OPEN//77 itself: the client plugin, its archive and its scripts | | `open77-webui` | The in-game interface runtime — the server browser you are looking at is drawn by it | | `red4ext` | The loader that gets the plugin into the game process at all | | `redscript` | The script compiler the game runs at boot | Everything else is yours to choose. The stack carries third-party packages — Cyber Engine Tweaks, Cyber Vehicle Overhaul, and the CVOO overlay on top of it — each with its author, licence and upstream link recorded, none of them mandatory, and none of them enabled behind your back. A package can also be **consent-gated**: switched off by default and refused until you have read what it does and said yes. That exists for changes that outlive the session — a tweak that alters your singleplayer game and keeps altering it after you stop playing OPEN//77 is not something a checkbox should do silently. The first attempt to enable one returns the points to read; your acceptance comes back on the same action. Your consent is remembered separately from your on/off choices, on purpose: if the warning attached to a package later changes, you are asked again rather than inheriting an answer you gave to a different question. ### Dependencies, conflicts and overrides Turning something on turns on what it needs. Turning something off is refused while something that needs it is still on, and the refusal names the dependant. Two enabled packages claiming the same file is normally a **hard refusal**, and it should be: a silent race for a path is how a mod set stops being reproducible. But some pairings are genuinely an overlay rather than a collision. CVOO ships 36 of Cyber Vehicle Overhaul's 39 files, with different bytes, and ships no `init.lua` at all — it is not a replacement for CVO, it is a layer on top of one. So a package can **declare** what it overlays, which means exactly three things: - **It implies a dependency.** An overlay is not standalone, so what it overlays is switched on with it — and unticking the package underneath is refused, naming the overlay. - **It suppresses the shared-path conflict for that one declared pair.** Any other two packages claiming a path is still refused. The rule is not weakened; it is given one auditable exception. - **The winner is the overriding package, deterministically** — never "whichever was written last". The rows show both ends of it: the overlay says what it overrides and how many files it took, and the package underneath says how many of its own files are currently somebody else's. ### What happens when you untick something Nothing is deleted. The launcher keeps a **content store** under `%LOCALAPPDATA%\Open77\modstore`, addressed by content hash, and the copy of every file a package displaced stays in it. So switching an overlay off **restores the original copy of every path it had taken**, byte for byte. That promise is load-bearing enough that applying a change deliberately fetches the displaced copies even though it does not install them — because without that, undoing a checkbox could require a download, and for a package whose bytes you supplied yourself there would be no download to make. ### Apply, and the compile that can refuse it **Apply** is the commit point, and it runs in three steps: work out the file set, compile the redscript, then write. The middle step is not a formality. Every `.reds` file in the game shares one compilation unit, so a single broken script anywhere makes the whole compile fail — and a failed compile silently reverts the game to its **vanilla scripts**. That drops OPEN//77's own bridge scripts with it, and multiplayer simply stops working, with no error you would ever see. You would get a game that starts, looks completely normal, and has no OPEN//77 in it. So the launcher compiles the set that would actually result — against a scratch copy, never your game folder — *before* moving a single file. If it does not compile, the whole apply is refused, the offending mod is named, the compiler's own errors are shown, and you are offered one click to switch that mod off and try again. **Apply is also refused while Cyberpunk 2077 is running**, including a session you started from somewhere else. Installing means deleting and relinking files a live game holds open. ### Mods you installed yourself The launcher scans the eight folders Cyberpunk mods live in — `archive/pc/mod`, `r6/scripts`, `r6/tweaks`, `r6/input`, `red4ext/plugins`, `bin/x64/plugins`, `engine/tools`, and CET's own mods folder — and sorts what it finds into three kinds: **ours** (a package we installed), **contested** (a package claims that path but somebody else's bytes are there — never blind-overwritten), and **foreign** (yours). User-installed mods appear in a separate section with their location, size and any compatibility warning. Version, author and licence are omitted when that information is unavailable. Two kinds of rule can catch one, and the difference matters: - **Switched off by default.** The launcher turns it off the first time it sees it, and *writes that decision down*. If you switch it back on, that answer sticks and no rule turns it off again. Your game, your choice. - **Blocked.** Refused while OPEN//77 is installed, because it breaks or cheats. This one is re-asserted every time, because it is a refusal rather than a default. Switching one of your own mods off is a **move, never a delete**. Its files go into the content store, and they only leave your game folder *after* their bytes have been written to the store and re-read and re-hashed there. Restoring copies them back, hash-verified, with their original timestamps. There is a *restore my mods* action that hands every held file back, blocked ones included — and uninstalling OPEN//77 does the same thing. If you use Vortex or Mod Organizer 2, they will put a quarantined file back, and that is fine. The scan is hash-and-path driven and re-runs every time, so a restored file is simply seen again; a blocked one is held again and says so, and a default you already overruled is left alone. Nothing here overwrites a file another manager owns. ## Browsing and joining a world The launcher's server browser is the *same interface as the game's own* — the same shell, the same list, the same connect panel — because it is literally the same web assets, driven by the launcher instead of by the game. The directory comes from the platform master, fetched by the launcher itself rather than by the page. Press connect on a world and the launcher starts Cyberpunk 2077 with your account session and a boot connect target, so the game comes up already joining that world instead of dropping you on the server browser. If a client is already running, it joins in place rather than launching a second one. The directory is live. Joining requires Alpha access on your account. If the list is empty, clear filters, refresh it and check the selected master and your connection. Local/private addresses are deliberately hidden from this list. ### History and Direct Connect **History** lists confirmed connections, most recent first, including localhost and private development servers. Entries show availability from the current directory when known; an unlisted raw address can remain **Unknown** rather than being declared offline. Failed connection attempts do not create history entries. Use **Direct Connect** in the launcher to enter `IP:port`, `hostname:port` or `[IPv6]:port`. It uses the normal connection flow, including authentication and any required mod/security confirmations. It is not an access bypass. ## Direct connect links A link of this shape boots the game straight into a world: ```text open77://connect?server= ``` It is what the Join buttons on the OPEN//77 website emit and what the launcher's own rows use, so a link a server owner posts in their Discord behaves identically to one on this site. The launcher registers the `open77` scheme for your user account on every start — idempotently, so a broken registration heals itself — and clicking such a link: - opens the launcher if it is closed; - **hands the link to the launcher you already have open**, over a local pipe, and brings that window to the front. You never get a second window; - **queues the target if you are not ready yet** — signed out, game unverified, or mods still updating. You sign in, it finishes, and the connect fires on its own. You do not have to click the link twice. A deep link is a shortcut through the launcher's interface, not through authentication: it drives exactly the same authenticated connect a manual click does. If anything fails, you land in the normal server browser with the error rather than on a hung screen. If you have no launcher installed, your browser shows its usual "no app for this link" prompt and nothing happens. ## How it updates itself The launcher checks for a newer version of itself against the platform's build allowlist, and offers it as a banner. Applying it is deliberately two-phase, so a running program is never overwritten under its own feet: 1. **Stage.** The new executable is downloaded, its **SHA-256 verified against the allowlist entry**, and written *beside* the current one. Nothing running is touched. A failed download or a hash that does not match leaves you exactly where you were, with a message and a manual download link. 2. **Swap.** The very first thing the launcher does on its *next* start is put the staged executable in place and relaunch it. The old one is kept as a backup until the swap is proven, and any failure falls through to simply starting the version you already had. So a launcher update lands on a restart, exactly once, and cannot leave you with a half-written exe. The banner offers you that restart when a version is staged. There is a setting for whether it checks at all. The client mod is a separate thing on a separate cadence, checked at every start before you reach the READY screen, and offered as its own banner while you are using the launcher. Installing it is refused while the game is running. Where the mod stack is in use, the mod is fetched **per package and by content hash**, which is what makes a routine OPEN//77 update small. The client payload weighs about 410 MB, but roughly 384 MB of that is a browser runtime that only changes when that runtime is bumped. Because each package is fetched separately and addressed by its content, a plugin-only update moves on the order of **8 MB of blobs instead of the whole 410 MB**. Switching between the stable and beta channels is cheap for the same reason: only what actually differs is downloaded. ## When something goes wrong **The log is at `%LOCALAPPDATA%\Open77\launcher.log`.** It is a plain text file, appended to, one timestamped line per event: sign-in, the directory fetch, the mod update, the launch, the world it was trying to connect you to, and any initialisation error. The window has no console, so this is the only place these end up. It is never rotated or trimmed, so it is also complete — attach it when you ask for help. There are a few command-line switches that answer questions faster than guesswork: ```text Open77Launcher.exe --detect-only locate and hash the game, print it, exit (no window, no network) Open77Launcher.exe --console the original one-pass console sign-in and launch flow Open77Launcher.exe --mods open straight on the mod menu Open77Launcher.exe --game-dir point it at a specific install ``` `--detect-only` is the one to reach for first: it tells you which install the launcher found and what it hashed to, without touching the network or your files. | Symptom | Where to look | |---|---| | "Windows protected your PC" on first run | Expected — the exe is unsigned. **More info → Run anyway**, and make sure you got it from the official site | | The window never appears | WebView2 Runtime. The launcher says so, and the log has the exception | | It cannot find your game | `--detect-only` to see what it tried, then pick the folder manually. Epic installs are not auto-detected | | A build warning you did not expect | The game auto-updated. The platform targets one exact build | | Apply is refused | Read the reason — it always gives one. `game_running` is the most common, and means exactly what it says | | "Another mod manager owns files OPEN//77 needs" | Vortex or MO2 has its own copy of a shared loader file — RED4ext's or redscript's. The launcher refuses to overwrite it rather than start a restore war. Let one manager own those files | | You were signed in yesterday and are signed out today | Sessions expire. An expired one is treated as signed out rather than half-working; sign in again | | The game starts but there is no OPEN//77 in it | Almost always a failed redscript compile reverting to vanilla scripts. The preflight exists to stop this, so check whether anything was installed outside the launcher | | The server list is empty | Clear filters, refresh and check the selected master/network; use History or Direct Connect for local servers | | Alpha access is required | Sign in with your Alpha account; request access with `/alpha apply` on Discord if needed. Downloading the launcher alone does not grant access | ## See also - [Your server in the launcher](launcher-for-server-owners.md) — the same machinery from a server owner's side, including the deep-link contract. - [How the platform works](/docs/platform) — what OPEN//77 is, and the exact requirements. - [Host your own server](/docs/host-a-server) — running a world of your own. --- Source: https://open2077.net/docs/community-hub # The Workshop: install and share resources The [Workshop](/workshop) hosts free community resources, gamemodes, maps, interfaces and tools. Creators publish packages; server owners install and manage them through Warden. The [front page](/workshop) shows featured, trending, new and recently updated creations. The [full library](/workshop/browse) has the filters. Every creation has its own page, and every creator has a public profile. The old `/community`, `/resources` and `/creators` addresses redirect to the new ones, so existing links keep working. Three things to know before you start: - A creation ships as **versioned releases**. A release is a ZIP that never changes after it is published. A fix always means a new version. - Moderators review every creation and every release before it becomes public. An upload that passed the automatic checks is not public yet. - You install through **Warden**, your server's admin console. It downloads the package, checks it, shows you exactly what it will change, and changes nothing until you accept. You never copy files by hand. If a control is greyed out, for example the hover clip section or the Workshop tab in Warden, that feature is not enabled where you are. It is not broken. ## Install a creation on your server 1. Find the creation in the [library](/workshop/browse). Narrow the list by category, by **Downloadable** or **Showcases**, by source code link and by tags, and change the sort order. On a phone, press **Filters**. 2. Open its page. Read **About**, **Installation** and **License**, then look at **Recent releases**. Each release has a **Compatibility** section listing the OPEN//77 server builds the creator says they tested. That is a declaration, not a promise. 3. In the **Install with Warden** box, press **Copy link**. The link looks like `https://open2077.net/workshop/auto-taxi`. That is all Warden needs. 4. Open your server's Warden and go to the **Workshop** tab. The first time, allow installs by adding the Workshop file gateway to `server.jsonc` and restarting the server: ```jsonc "warden": { "enabled": true, "hubFileGatewayOrigin": "https://files.open2077.net" } ``` Browsing works without it; installing does not. 5. Paste the link into the search field and press **Search**. Warden looks the creation up on the OPEN//77 master, shows it as a tile and opens its releases. The short form `workshop:auto-taxi` works too. You can also type a search term instead and use the category chips and the **Sort** menu. 6. Under the release you want, press **Create installation plan**. 7. Read the plan: the files it adds or replaces, the permissions the resource asks for, the load rules it proposes, and whether a restart is needed. Resolve any blocker it reports. 8. Tick the consent box and press **Install reviewed plan**. Follow the **Installation job** panel. The install is done when the job reports **committed**. Ordinary Lua resources start in the running server without a restart. The plan adds a load rule for the resource, so it also starts every time the server boots. The server hands the client half of the resource to every player who connects, signed, exactly as it does for your own resources. Server scripts never leave the server. In the **Resources** tab, an installed package carries a chip such as **Workshop · v1.0.1**, and the summary counts how many resources come from the Workshop. A creation labelled **Showcase** has nothing to install. It exists to show work. You can also download the ZIP yourself from the release list, without signing in, to inspect it or to install by hand on a test server following [Server resources](/docs/server-resources). Downloading does not install anything. Never extract an unknown package onto a live server: a wildcard load rule can start it immediately. ## Update, roll back or uninstall All three happen in Warden's **Workshop** tab. Every one of them shows you a plan first and applies it only after you tick the consent box. **Update.** Search the same creation, pick the newer release and press **Create installation plan**. Warden compares the new release with what is installed, including files you changed locally, and shows the differences. Review and install as for a first install. Automatic updates do not exist. **Roll back.** Open **Installed from the Workshop**, unfold **History, restart staging and storage** and press **Review rollback** next to an earlier job. Warden builds a fresh reverse plan against the server as it is now. Your current configuration and user files are kept as the plan describes. Rollback is only offered while the backup from that job is still retained, and it refuses to run if the old release was revoked or a current dependency needs a different version. **Uninstall.** In **Installed from the Workshop**, tick the package under **Pick** and press **Review uninstall**. Warden checks what else depends on it, including resources you did not select, and shows what it removes and what it keeps. Your configuration and user files stay behind. If they do, an inert directory may remain with the manifest renamed so it cannot load. The full Warden reference, including plan fields, job states, restart staging and backup retention, is in [Manage Workshop resources with Warden](/docs/community-hub-warden). ## Publish a creation ### What you need - An OPEN//77 account with a **verified email**. The editor refuses to open otherwise. - A [creator profile](/account/profile) with a public handle. - A resource that already runs on a server, with its `open77.lua` manifest. The Workshop does not replace the manifest; it packages it. See [the resource runtime](/docs/resource-runtime) if you are starting from zero. ### The five steps Open [My creations](/account/creations) and start a creation. The editor autosaves your text in the tab until you save the draft. Its five steps are: 1. **Basics.** Slug, title, summary, type (**Resource** or **Showcase**), category, maturity and tags. Pick the slug carefully: it becomes the public address that server owners paste into Warden. 2. **Showcase.** Description, installation notes, cover, screenshots, videos and the optional hover clip. See "Screenshots, videos and the hover clip". 3. **Release.** Version number, changelog, license, installation notes, tested builds and required resources, then the ZIP itself or a GitHub import. A showcase skips this step. 4. **Validation.** The result of the automatic package inspection. Fix anything it blocks on. See "Why was my upload rejected?" below. 5. **Preview and submit.** The page as visitors will see it. Tick the rights box, save, then press **Submit for review**. ### Screenshots, videos and the hover clip **Images.** JPEG, PNG or WebP, up to 10 MiB and 40 megapixels each. Animated images are rejected. Upload an image, wait for processing, press **Attach to draft**, then save. The first image is the cover; you can add up to eight more screenshots. Each needs an **Image description** (read by screen readers and search engines) and can have a caption. Images are re-encoded and stripped of metadata before they are served. **Videos.** Up to two YouTube or Vimeo links. Visitors see a preview and load the player only when they press play. **Hover clip.** One short muted clip that plays when someone rests the pointer on your tile in the library. WebM, MP4 or GIF, up to **8 seconds** and **8 MiB**. The server re-encodes it muted at up to 640 pixels wide and drops sound, subtitles and metadata. Upload it, wait for processing, press **Use as hover clip**, then save the draft. Keep a cover image as well: the clip is not used anywhere else. Only use media you have the right to share. Screenshots are showcase material; they are never installed. ### Versions, changelogs and licenses A published release is **immutable**: its bytes, its file list and its SHA-256 never change. To ship a fix, create a new version with a higher number. Older versions stay downloadable so a server owner can roll back. Each release carries a **changelog** in Markdown. Say what changed and what a server owner must do to upgrade, especially when a config template changed. Each release needs a **license and attribution** text. Name the license and list any third-party notices. A free download does not by itself allow modification or redistribution; the license does. Do not upload repacked game files or someone else's work. A version with a prerelease suffix, such as `1.1.0-beta.1`, is listed as a prerelease. The page hides prereleases behind **Show prerelease versions**, and Warden only installs one when the operator ticks **Allow prerelease versions** on purpose. ### Import a release from GitHub In a release draft, open **Import a ZIP from a GitHub release**. Connect your GitHub account when asked, then pick the repository, the release and the ZIP asset. Check the tag, asset name, size and SHA-256 before you confirm. Only uploaded ZIP assets up to 100 MiB with a GitHub SHA-256 digest can be imported; source archives generated by GitHub cannot. The import copies that one asset into the same private inspection pipeline as an upload. It does not follow the branch afterwards; if the upstream asset changes, publish a new version. A repository URL in the source field is a link, not an import. Manage the connection at [GitHub connections](/account/github). Owning the repository does not settle redistribution rights for everything in it. Check the licenses of bundled dependencies and assets. ### Review: what happens after you submit Submission runs in two stages. 1. **Inspection**, automatic and private. The worker unpacks the ZIP, checks its layout and manifest, scans for secrets and malware, and re-encodes media. The Validation step shows the result with a code and a correction. 2. **Moderation**, by a person. Moderators check that the creation is what it says it is, that it contains only content you may distribute, that it has a stated license, and that it is not abusive or misleading. The page and each release are reviewed separately. A downloadable creation needs a validated release before it can be submitted. Until both stages pass, nothing is visible to anyone but you and your maintainers. The decision arrives in your [inbox](/account/notifications) and in the creation's activity history. If the answer is no, you can fix the draft and resubmit, or open one appeal per decision with your reasons and evidence. ### Working with others, archiving and deleting From My creations, invite another verified creator by handle as a maintainer. They get access when they accept in [Invitations](/account/invitations). To hand a creation over, offer ownership to a maintainer; you remain owner until they accept. Archive a published creation when you stop maintaining it. The page and its downloads stay up, comments close and edits freeze. You can reopen it later. Delete an unpublished draft with **Delete unpublished draft**. A creation with any published history cannot be deleted this way; archive it instead. Deletion frees your draft allowance. There is no undo. ## Export from your server with Warden Warden can package a resource that already runs on your server and send it to the Workshop as a **private draft** on your creator account. You then finish it on the website. Your Warden login needs the `hub.publish` permission. 1. In the **Workshop** tab, open **Creator account**. Give the connection a label and press **Connect creator account**. Warden shows a code and an **Open creator approval** button. 2. Open the approval page, sign in to your creator account, check that the code matches, and approve the requested scope. Never type your account password or a token into Warden. 3. Unfold **Export your resources**. Tick the resources to include and enter a **Release version**. Press **Preview export**. 4. Read the review: included and excluded files per resource, findings, warnings and external dependencies. The secret scan is a heuristic; you still read the list. Tick the consent box and press **Create reviewed ZIP**. 5. When the ZIP is ready, you can press **Download reviewed ZIP** to inspect it locally. 6. Open **Send ZIP to creator draft**. Fill in the title, slug, summary and category for a new creation (or leave the project ID pointing at an existing one), the license, changelog, installation notes, tested builds and required resources. Tick the rights box and press **Send private draft**. 7. **Private draft accepted for editing** means the Workshop holds a private draft. It is not published. Press **Open creator draft** to add media, complete the release and submit for review on the website. The transfer keeps running if you close the browser. After a server restart or a lost response, sign back into the same Warden account and open **Saved creator transfers** to resume the matching upload instead of creating a second draft. End the connection with **Disconnect creator** in Warden or from [Warden connections](/account/connections) on the website. Disconnecting cancels unsent exports; drafts already accepted stay on the website. Warden never exports your server identity, signing keys, live configuration, logs or player data. The host-side details are in the [Warden export guide](https://github.com/Open2077/open77-base/blob/feat/community-hub/docs/community-hub-warden-export.md). ## Discussion, upvotes, saves, follows and reports Sign in with a verified account to take part. Browsing and downloading need no account. - **Upvote** a creation once; you can take it back. Creators cannot upvote their own work. - **Save** puts a creation in your private [saved list](/account/saved). - **Follow** subscribes you to its new releases. Saving and following are separate. Manage them under [subscriptions](/account/subscriptions); new releases and replies land in your [inbox](/account/notifications). - **Discussion** lives on the creation's page. Ask questions and report reproducible problems with the release, your server build, what you expected and sanitized logs. Creators can pin answers and mark threads resolved. You can edit or delete your own comments; deleting keeps the replies underneath. Do not post credentials or other players' private data. - **Report** a creation, a release or a comment for abuse, malicious content or a licensing problem. Give evidence and the exact version. A report goes to moderators only and does not remove anything by itself. Unsent comments, appeals and edits stay in the tab while you move between Workshop pages. They are lost when the tab closes. ## Details: what the ZIP looks like Put `open77.lua` at the root of the ZIP or inside one resource folder: ```text auto-taxi-1.0.0.zip auto_taxi/ open77.lua server/main.lua client/main.lua shared/config.example.lua README.md LICENSE ``` To ship several resources in one package, add an `open77-hub.json` file listing each resource root. Each release installs as one unit. Every resource in a bundle needs a distinct name that matches its folder. Include only the resource, its license and an explicitly prepared example configuration. Leave out server identities, accounts, database dumps, live configuration, tokens, logs and player data. Inspection scans for these and rejects the upload when it finds them. Limits: 100 MiB compressed, 500 MiB when expanded, 10,000 entries in total across the ZIP and any nested archives. [Example packages](https://github.com/Open2077/open77-base/tree/feat/community-hub/examples/community-hub/v1) show the single-resource, bundle and preload layouts. They contain diagnostic Lua only. The link points at the development branch until a release is pinned. ## Details: Warden permissions | Permission | Allows | | --- | --- | | `hub.view` | Browse the Workshop from Warden and read releases. | | `hub.manage` | Create and apply install, update, uninstall and rollback plans. | | `hub.publish` | Connect a creator account and export resources to a draft. | | `restart.schedule` | Schedule the restart that staged preload assets need. | These are Warden permissions on your server. They have nothing to do with your website account. Being a creator on the website grants nothing in Warden. ## Details: assets that load before the game starts Some packages declare **preload** assets: game files that must be in place before Cyberpunk starts. These cannot be swapped live. Warden stages them privately and marks the job **awaiting_restart**. Nothing changes on the live server until it restarts. Under **History, restart staging and storage › Restart staging**, an operator with `restart.schedule` can press **Schedule server restart**; anyone with `hub.manage` can press **Cancel staged restart**. Players must relaunch through the launcher when the required asset set changes. While a restart is pending, no other Workshop change can start. At boot, Warden checks that the release is still published and that nothing was edited in between; if the check fails, it boots the previous installation and you review a new plan. ## FAQ **Is it free?** Yes. Browsing, downloading and installing cost nothing, and publishing costs nothing. **Can I sell what I make?** No. The Workshop has no prices, checkout or payouts. Every creation is free to download. Your license still decides what people may do with it afterwards. **What happens if a release is revoked?** Moderators can withdraw a release, for example after a rights complaint or a malware report. Its download stops working and it disappears from recommendations. Servers that already installed it keep running; nothing is deleted from them. Warden marks the package **revoked** in **Installed from the Workshop**, blocks new installs and updates to that version, and refuses to roll back to it. Review the creation and decide whether to uninstall. Creators see the decision in their inbox and can appeal. **Why was my upload rejected?** The Validation step shows a code. In plain words: - `package_layout` – `open77.lua` is not at the ZIP root or inside one resource folder, or a bundle does not list its roots in `open77-hub.json`. - `package_metadata` – `open77-hub.json` does not match the schema or the release version. - `package_unmapped_file` – files sit outside the declared resource roots. - `resource_manifest` – `open77.lua` has a syntax error, a bad name, a bad entrypoint or references a missing file. - `resource_name_collision` – two resources share a runtime name, or a name does not match its folder. - `archive_path`, `archive_collision`, `archive_link`, `archive_directory`, `archive_length` – the ZIP contains traversal or absolute paths, names that differ only by case, symbolic links, unusual entries, or inconsistent sizes. Rebuild it from the original files with ordinary paths. - `archive_encrypted` – the ZIP has a password. Export it without one. - `archive_budget` – too big when expanded, too many entries or too deep a nesting of archives. - `archive_empty` or `invalid_zip` – nothing inside, or not a valid ZIP. - `preload_archive`, `preload_capability` – a declared preload archive does not follow the supported mod layout, or uses content the platform does not accept. - `package_signature` – the malware or secret scanner flagged content. Inspect the package locally and remove it. - `inspection_temporarily_unavailable` – processing is down. Wait and refresh; do not create a second version. For images: `image_format` (not a JPEG, PNG or WebP), `image_animation` (animated), `image_decode` (cannot be decoded), `image_size` (over 10 MiB) or `image_dimensions` (over 40 megapixels or 20,000 pixels on a side). For the hover clip: `clip_format` means the file is not a WebM, MP4 or GIF with one video stream; `clip_duration` means it is longer than 8 seconds; `clip_size` means it is larger than 8 MiB; `clip_dimensions` means a frame is over 4096 pixels on a side. **My install says "awaiting_restart". Is it installed?** Not yet. The files are staged privately. Schedule the restart in Warden; the install commits on the next boot if every check still passes. **Does downloading a ZIP install it?** No. Either paste the link into Warden or install by hand on a test server first, following [Server resources](/docs/server-resources). --- Source: https://open2077.net/docs/server-startup # Server startup arguments and logging Configure an OPEN//77 dedicated server from a hosting panel, service definition or shell without rewriting `server.jsonc`. Startup overrides and configurable log levels require server **2.31.13+op77.91 or newer**. Complete the [server setup](/docs/host-a-server) and configure your [license](/docs/server-licensing) first. Run commands from the unpacked server directory. Startup options are read when the process starts; restart the server after changing them. ## Choose a log level Linux: ```bash ./Open77.Server --config server.jsonc --log-level debug ``` Windows PowerShell: ```powershell .\Open77.Server.exe --config server.jsonc --log-level debug ``` The level is a minimum severity, not a numbered verbosity setting: | Level | Open77 log entries retained | | --- | --- | | `trace` | All levels, including available trace diagnostics. | | `debug` | Debug, information, warnings and errors. | | `info` | Information, warnings and errors. This is the default. | | `warn` | Warnings and errors. | | `error` | Errors only. | Use `info` for normal operation and `debug` when investigating a problem. Debug and trace include runtime/simulation details and successful resource-download requests as well as failures. Detailed logging can produce substantial output on a busy server; return to `info` after collecting the relevant evidence. You can also add a top-level section to `server.jsonc`: ```jsonc "logging": { "level": "info" } ``` Or use `--set logging.level=debug` or the environment variable `OP77_CONFIG__LOGGING__LEVEL=debug`. Filtering applies to Open77's structured console output, retained log buffer and Warden's live console. Open77 resource `print`/information messages are hidden at `warn` and `error`; filtered messages cannot be recovered from Warden afterwards. Startup errors and mandatory setup instructions remain visible independently of the filter. Framework, third-party and native diagnostics can have separate logging controls; this is not a blanket filter over everything written to standard output. Low-level native transport diagnostics use the separate `OP77_GNS_DEBUG` option. ### Legacy verbose switch `--verbose` or `logging.verbose=true` selects `trace`. `--no-verbose` or `--verbose=false` selects `info`. An explicit, non-null `logging.level` always takes priority over `logging.verbose`, even when `--verbose` appears later on the command line. For example, a file with `"level": "warn"` remains at `warn` when you add only `--verbose`. Use `--log-level debug` to change the level explicitly, or clear it to use the legacy switch again: ```bash ./Open77.Server --config server.jsonc --set logging.level=null --verbose ``` ## Override any configuration field The following forms are equivalent: ```bash ./Open77.Server --set network.maximumPlayers=64 ./Open77.Server --network.maximumPlayers 64 ./Open77.Server --network.maximumPlayers=64 ./Open77.Server --set=network.maximumPlayers=64 ``` Field names are case-insensitive. These options correspond to configuration fields, not Lua API names. Download settings are under `resources.download`, not a top-level `download` section. Precedence, from lowest to highest: 1. Built-in defaults. 2. The selected JSONC configuration file. 3. `OP77_CONFIG__...` environment variables. 4. Command-line overrides. When the same field occurs repeatedly on the command line, the last value wins, including when mixing shorthand and dotted options. Semantic validation runs after overrides, so a hosting panel can complete an unset public endpoint in a template. The file must still have valid JSONC syntax and field types. Strings are plain text; quote values containing spaces for your shell. Booleans use `true` or `false`. Arrays and objects use JSON. `null` clears a nullable value; the JSON string `"null"` is a literal string. Arrays can be replaced as a whole or an existing element changed using a numeric dotted index. For example, in Bash: ```bash ./Open77.Server --config server.jsonc \ --name "My community server" \ --set 'identity.tags=["roleplay","english"]' \ --set 'convars.server_region=eu' \ --log-level info ``` Overrides affect the running process only: they do not rewrite `server.jsonc` or its comments. Save the arguments/environment in your hosting panel or service definition to keep them across restarts. Warden edits the underlying file; an override of the same field will still win at the next boot. ### Common startup options | Option | Purpose | | --- | --- | | `--config PATH` | Select the configuration file; `--config=PATH` also works. Default: `server.jsonc`. | | `--set section.field=value` | Override any configuration field; repeat as needed. | | `--public-ip IP` | Fill advertised game/download addresses from a provider-supplied IP. | | `--port NUMBER` | `network.port`: game UDP port. | | `--public-endpoint HOST:PORT` | `network.publicEndpoint`: externally reachable game address. | | `--max-players NUMBER` | `network.maximumPlayers`. | | `--name TEXT`, `--description TEXT` | `identity.name` and `identity.description`. | | `--visibility Public\|Unlisted\|Private` | `identity.visibility`: server directory visibility. | | `--tick-rate NUMBER`, `--snapshot-rate NUMBER` | `simulation.tickRate` and `simulation.snapshotRate`. | | `--log-level LEVEL` | `logging.level`: minimum Open77 log severity. | | `--verbose`, `--no-verbose` | Legacy logging switch; an explicit level takes priority. | | `--no-setup` | Skip the interactive first-run wizard for an already provisioned server. | | `--setup`, `--reconfigure` | Open the setup wizard. | | `--setup-listen URL` | Set the first-run setup listener address. | | `--check-config` | Validate the effective configuration without starting the server. | | `--help`, `-h` | Show usage without starting first-run setup. | ## VPS and hosting-panel example Replace `203.0.113.10` with your VPS's actual public IP. The provider or autoinstaller supplies it; the server does not discover the public IP itself. ```bash ./Open77.Server --no-setup --config server.jsonc \ --public-ip 203.0.113.10 --port 11778 --max-players 32 \ --log-level info ``` PowerShell equivalent: ```powershell .\Open77.Server.exe --no-setup --config server.jsonc --public-ip 203.0.113.10 --port 11778 --max-players 32 --log-level info ``` With the default download port, `--public-ip` supplies these values unless the corresponding fields are explicitly overridden at startup: | Configuration field | Generated value | | --- | --- | | `network.publicEndpoint` | `203.0.113.10:11778` | | `resources.download.enabled` | `true` | | `resources.download.listenUrl` | `http://0.0.0.0:11779/` | | `resources.download.publicBaseUrl` | `http://203.0.113.10:11779/` | The game address uses the final game port. Download addresses use the configured download-listener port, which defaults to `11779`. Binding to `0.0.0.0` listens on all IPv4 interfaces; it is not an address players can connect to. To specify every field yourself: ```bash ./Open77.Server --no-setup --config server.jsonc \ --set network.port=11778 \ --set network.publicEndpoint=203.0.113.10:11778 \ --set network.maximumPlayers=32 \ --set resources.download.enabled=true \ --set resources.download.listenUrl=http://0.0.0.0:11779/ \ --set resources.download.publicBaseUrl=http://203.0.113.10:11779/ \ --log-level info ``` Specific environment or command-line overrides win over `--public-ip` regardless of argument order, including children supplied in a whole JSON section. This lets you retain an HTTPS public URL, a loopback listener behind a reverse proxy, or a different external NAT port. A file value alone does not prevent `--public-ip` from replacing an old loopback address. `--public-ip` accepts an IPv4 or IPv6 literal without a port. IPv6 advertised addresses are bracketed automatically; for an IPv6 download listener, explicitly set `--resources.download.listenUrl=http://[::]:11779/`. Allow game traffic on **UDP 11778** and resource downloads on **TCP 11779**, or your chosen ports. Configure NAT/container port mappings separately. Startup arguments do not change firewall rules. The download listener is separate from Warden and the resource HTTP-handler listener; `--public-ip` does not configure those. `--no-setup` does not create a license, install resources or bypass authentication. ## Environment variables Use the prefix `OP77_CONFIG__` and replace each dot in a configuration path with two underscores. For Linux, containers and hosting panels: ```bash export OP77_PUBLIC_IP=203.0.113.10 export OP77_CONFIG__NETWORK__PORT=11778 export OP77_CONFIG__NETWORK__MAXIMUMPLAYERS=32 export OP77_CONFIG__RESOURCES__DOWNLOAD__LISTENURL=http://0.0.0.0:11779/ export OP77_CONFIG__LOGGING__LEVEL=debug ./Open77.Server --no-setup --config server.jsonc ``` Windows PowerShell: ```powershell $env:OP77_PUBLIC_IP = "203.0.113.10" $env:OP77_CONFIG__NETWORK__PORT = "11778" $env:OP77_CONFIG__NETWORK__MAXIMUMPLAYERS = "32" $env:OP77_CONFIG__LOGGING__LEVEL = "debug" .\Open77.Server.exe --no-setup --config server.jsonc ``` `OP77_PUBLIC_IP` is the fallback for `--public-ip`; specific `OP77_CONFIG__...` address fields still take priority over the generated values. These shell commands set variables for that shell and its child processes, not permanently for a Windows service or systemd unit. Configure the service/panel environment separately. Keep secrets in protected environment variables rather than command-line arguments, which process listings or panel logs can expose. Existing secret variables remain available: `OP77_LICENSE_KEY` and `OP77_DATABASE_CONNECTION`. See [SQL database setup](/docs/database) for database credentials and permissions. ## Validate and troubleshoot Append `--check-config` to the same command and environment you intend to use: ```bash ./Open77.Server --no-setup --config server.jsonc \ --public-ip 203.0.113.10 --port 11778 --max-players 32 \ --log-level debug --check-config ``` Exit code `0` means configuration validation passed. Exit code `2` means a configuration/argument error; the diagnostic identifies the affected field. Check mode does not open listeners or setup, contact the Master, run resources, or write state. It does not test firewall reachability, database credentials or installed resource contents. Use `info` or `debug` to include the network summary; at `warn`/`error`, the validation result remains visible but information-level summary lines are filtered out. | Symptom | What to check | | --- | --- | | Unknown option or field | Use `--help`; use `resources.download.*`, not `download.*`. | | Invalid value or array index | Check the JSON type, range and existing array length. Fix JSONC syntax/type errors in the file before applying overrides. | | `--verbose` appears to do nothing | A non-null `logging.level` is set in the file, environment or command line. Set `--log-level debug` explicitly. | | Warden changes disappear after restart | A startup argument or environment variable still overrides that field. | | No resource prints in Warden | `warn` and `error` suppress information-level messages. Restart with `info` or `debug`. | | Server is listed but downloads fail | Check the advertised public download URL and TCP port from outside the VPS. A valid configuration does not prove network reachability. | When requesting help, include the server version, exit code, relevant log lines and startup arguments with secrets removed. Do not share an unredacted environment dump or configuration file. --- Source: https://open2077.net/docs/database # Configure a SQL database Configure the built-in asynchronous **MySQL/MariaDB** bridge for server Lua. `MySQL` and `Open77.database` expose the same API; no oxmysql resource is required. PostgreSQL, SQLite and Microsoft SQL Server are not supported. SQL runs on the server side, never on a player's PC. The bridge is disabled by default. The server distribution does not install a database service or create SQL credentials for you. For a small local key/value store without a SQL service, consider [server KVP](/docs/server-api#resource-keyvalue-store) instead. ## 1. Install MySQL or MariaDB You can use an existing compatible service. For a new Debian/Ubuntu host, install MariaDB from the configured distribution repositories: ```bash sudo apt update sudo apt install mariadb-server mariadb-client sudo systemctl enable --now mariadb sudo systemctl status mariadb sudo mariadb-secure-installation ``` On Windows, use the MariaDB installer, enable the database as a Windows service and keep its TCP port at `3306` unless another service already uses it. For other distributions or installer details, follow the [official MariaDB installation guide](https://mariadb.com/docs/server/mariadb-quickstart-guides/installing-mariadb-server-guide). The simplest arrangement is SQL and OPEN//77 on the same host, with SQL listening on loopback. Players connect to the game server, **not** to port 3306. Do not open that port to the public Internet for a local database. ## 2. Create a database and a dedicated account Open an administrative SQL session. On a typical Debian/Ubuntu MariaDB installation, `sudo mariadb` uses the local administrative socket. On Windows, use the installed client or a database manager with your administrator account. Run this once, replacing the password placeholder with a unique secret: ```sql CREATE DATABASE open77 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'open77'@'127.0.0.1' IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_PASSWORD'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON open77.* TO 'open77'@'127.0.0.1'; ``` These grants allow normal reads/writes and common table migrations in this database only. Review each resource's schema requirements: add privileges such as `DROP` only when its reviewed migration actually requires them. Do not give the game server the SQL administrator account or global `*.*` privileges. SQL accounts include a host component. An account at `127.0.0.1` is not a catch-all account for remote servers; MariaDB distinguishes TCP loopback from `localhost` socket authentication. Keep the account host and connection method consistent. See [MariaDB's account rules](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/create-user). Test the same TCP connection that OPEN//77 will use, from the game server's machine: ```bash mariadb --protocol=TCP --host=127.0.0.1 --port=3306 --user=open77 --password open77 ``` The client prompts for the password; do not put it directly in the command. The equivalent MySQL client command starts with `mysql`. In that session: ```sql SELECT DATABASE(), CURRENT_USER(), 1 AS connection_ok; ``` This proves authentication and database selection, not every resource's migration permissions. ## 3. Configure OPEN//77 Merge this object into the **existing** `server.jsonc` you actually launch; do not replace the rest of the file: ```json { "database": { "enabled": true, "connectionString": "", "connectionStringEnvironmentVariable": "OP77_DATABASE_CONNECTION", "maxRows": 10000 } } ``` | Setting | Meaning | |---|---| | `database.enabled` | `false` by default. Must be `true` to create the bridge. | | `database.connectionString` | An explicit nonblank value takes precedence over the environment. Leave it empty when using an environment secret. | | `database.connectionStringEnvironmentVariable` | Name of the variable to read; defaults to `OP77_DATABASE_CONNECTION`. This field is a variable name, not the password. | | `database.maxRows` | Maximum rows returned by one query: default `10000`, accepted range `1..1000000`. Extra rows are truncated with a warning, not paginated automatically. | Supply a [MySqlConnector connection string](https://mysqlconnector.net/connection-options/) to the **dedicated-server process**. This is a semicolon-separated string, not a `mysql://` URL or FiveM's `set mysql_connection_string` directive. Linux, for a manual development launch from this shell: ```bash export OP77_DATABASE_CONNECTION='Server=127.0.0.1;Port=3306;Database=open77;User ID=open77;Password=REPLACE_WITH_A_UNIQUE_PASSWORD;' # Launch your normal OPEN//77 server command from this same shell. ``` Windows PowerShell, for a manual development launch: ```powershell $env:OP77_DATABASE_CONNECTION = 'Server=127.0.0.1;Port=3306;Database=open77;User ID=open77;Password=REPLACE_WITH_A_UNIQUE_PASSWORD;' # Launch your normal OPEN//77 server command from this same PowerShell session. ``` These examples show syntax with a placeholder. Entering a real secret directly can leave it in shell history; use your host's protected secret/configuration mechanism for production. For systemd, containers or hosting panels, configure the environment in that service/container/panel instead: exporting it in an unrelated SSH shell does not change a running service's environment. Restrict access to any environment file containing credentials. OPEN//77 does not implicitly load an arbitrary `.env` file. Restart the dedicated server after changing these settings. Reloading only a Lua resource does not recreate the server-wide database bridge. An enabled bridge with no effective connection string fails configuration validation. ## Remote SQL and containers For a separate SQL host, use its reachable hostname in `Server`, create a SQL account restricted to the game server's connecting address and restrict the database firewall to that source. In a container, `127.0.0.1` means that container; another container normally needs its service/DNS name and a shared network. Use a persistent volume for the SQL data directory. For remote production connections, use TLS with certificate and hostname validation: ```text Server=db.example.net;Port=3306;Database=open77;User ID=open77;Password=REPLACE_WITH_A_UNIQUE_PASSWORD;SslMode=VerifyFull; ``` If your provider uses a private CA, add its supplied `SslCa` file path. The hostname must match the certificate. `SslMode=Required` encrypts but does not validate the certificate's identity; it is not equivalent to `VerifyFull`. See the [connector's TLS options](https://mysqlconnector.net/connection-options/#ssltls-options). ## 4. Verify readiness from Lua Only **server scripts** with `database.access` can use SQL. Put these two files in a resource named `sql_probe` under your configured resources directory: ```lua -- open77.lua resource "sql_probe" version "1.0.0" server_script "server.lua" permissions { "database.access" } ``` ```lua -- server.lua local queued, reason = MySQL.ready(function() local ok, result = pcall(function() return MySQL.scalar.await("SELECT ?", { 77 }) end) if not ok then print("[sql_probe] query failed: " .. tostring(result)) return end print("[sql_probe] connected; parameterized SELECT returned " .. tostring(result)) end) if not queued then print("[sql_probe] readiness refused: " .. tostring(reason)) else local ready, state = MySQL.isReady() print("[sql_probe] state: " .. (ready and "ready" or tostring(state))) end ``` From the **server console** or Warden console: ```text refresh ensure sql_probe ``` Expected server messages include: ```text Database bridge enabled (maxRows=10000). Database bridge reachable; MySQL.ready handlers released. [sql_probe] connected; parameterized SELECT returned 77 ``` The first message alone only means the bridge is configured. The second is logged after a real `SELECT 1` probe succeeds. The Lua check additionally verifies the resource's permission, scheduler and parameter binding without creating or changing any tables. `MySQL.ready` is a startup gate, **not a continuous health check**. Before the first successful connection, the host retries with a backoff up to 30 seconds. After that first success, handle failures of individual queries; readiness does not revert to false during a later outage. ## 5. Use SQL in resources Callback methods receive `(result, error)`. The `.await` variants must run in a host-managed coroutine, such as `CreateThread`, an event handler or a `MySQL.ready` callback. Queries run off the server tick; their continuations resume on the owning resource's scheduler. ```lua MySQL.ready(function() local ok, reason = pcall(function() MySQL.update.await([[ CREATE TABLE IF NOT EXISTS my_resource_settings ( setting_key VARCHAR(64) PRIMARY KEY, setting_value VARCHAR(255) NOT NULL ) ]]) MySQL.update.await([[ INSERT INTO my_resource_settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) ]], { "welcome", "Welcome to Night City" }) end) if not ok then print("[my_resource] database setup failed: " .. tostring(reason)) end end) ``` Bind values with `?` or named `@parameters`; never concatenate player input into SQL. Use transactions for changes that must succeed together and handle rollback results. Prefix tables with the resource name and key player data by a persistent authenticated identity, not a temporary player/session ID. **All resources with `database.access` share this server's configured database and SQL account.** The permission does not create a private schema for each resource. Enabling SQL also activates persistence paths in other loaded resources that requested it, such as appearance storage. Review those resources and back up the database before changing a live server's persistence behavior. See the [database API reference](/docs/server-api#database) for method shapes, readiness and transactions, and the [resource isolation explanation](/docs/resource-runtime#the-database-bridge-is-per-server-not-per-resource). ## Troubleshooting | Symptom | What to check | |---|---| | Startup rejects an enabled database configuration | The effective connection string is empty, the environment variable name is blank or `maxRows` is outside its allowed range. | | `Database bridge disabled.` / `database_unavailable` | Check the launched configuration path and `database.enabled`; restart the server after changes. `MySQL.ready` will not run when disabled. | | `database_connecting` | The initial probe is pending. Wait for the reachable message. | | `database_unreachable` / configured but not answering | Read the first probe warning; test the same host, port and account from the server machine. Check the SQL service, network and credentials. | | Access denied | Check password, account host, authentication method and grants. A local administrator's socket login does not prove the application TCP login works. | | Unknown database | Create the named database and check its exact spelling; the bridge does not create it. | | Connection refused or timeout | Check the SQL listener, TCP port, container network, firewall and host name. Do not open SQL to every IP as a workaround. | | TLS/certificate failure | Use the certificate's hostname, the correct CA and a valid server certificate. Check the host clock. Do not disable verification to hide a production configuration error. | | `permission_denied:database.access` | Declare the permission in the calling resource and reload that resource. | | `MySQL` is missing in Lua | The code is running on the client, or on a server build without this API; keep database code in `server_script`. | | Missing table or migration denied | Run the resource's reviewed migration and grant only its required privileges on this database. | | Queries fail after the ready callback ran | Treat each query failure explicitly. Startup readiness is latched, not a live availability guarantee. | | Fewer rows than expected | Check the `maxRows` warning; use explicit ordering and bounded/paginated queries instead of relying on truncation. | Before sharing logs, remove credentials, connection strings and sensitive query data. Keep regular database backups and test restoration to a **separate** database. A copy of `resources/` is not a backup of SQL data. ## Implementation references The operator settings and lifecycle above are defined by `DatabaseConfiguration` and `ResolveDatabaseConnectionString` in the server's configuration loader, `DatabaseService` for the real readiness probe, and the [platform database guide](https://github.com/Open2077/open77-base/blob/main/docs/database.md). These are independent of the newer client WebUI changes; configuring SQL does not require a browser UI or a client-side database password. --- Source: https://open2077.net/docs/launcher-for-server-owners # Your server in the launcher Make your server available through the launcher directory, History or Direct Connect. Direct Connect accepts hostnames and IP addresses, including local development endpoints. This page is the operator's half of that contract. The player's half is [The OPEN//77 launcher](launcher.md). Everyone with Alpha access can download the server and start building without a separate developer application. Joining requires an Alpha account. Local/private endpoints are excluded from the public directory but remain available through History and Direct Connect. See [Alpha access](/docs/alpha-access) for setup and the Discord `/alpha apply` command. ## How a world reaches a player There is no registration form and no submission queue. Your server publishes itself: 1. Your server boots with a license key and registers with the master (see [Server licensing](/docs/server-licensing)). 2. The master's public directory, `GET /api/v1/servers`, starts returning it. 3. The launcher fetches that directory and draws a row per server. 4. The player presses connect on a row. The launcher does not discover servers on the local network, does not accept a hand-typed endpoint from the browser screen, and does not read a favourites file you can seed. Being in the master's directory is the only way in. That is also why a branding mistake can remove you entirely rather than degrade you: if your registration is refused, you are not a badly-drawn row, you are an absent one. [Branding your server](server-branding.md) covers the one budget that catches people. ## What one row is made of The catalogue entry the master publishes for you carries exactly these fields: | Field | What it is | |---|---| | `id` | The master's server id — the portable public identifier, and the thing a deep link carries | | `name`, `description` | What the row and its detail panel say | | `locale` | A BCP-47 tag. The website projects it onto a language chip and a region bucket | | `tags` | Free-form. The first tag naming a known mode is what the browser files you under; anything else reads as "Custom" | | `website`, `discord` | Your links, or null | | `connectEndpoint` | `host:port` — the address a client actually dials | | `connectedPlayers`, `maximumPlayers` | The population counter | | `expectedGameBuild` | The game build your server expects a client to be on | | `serverVersion`, `protocol` | Your build, and the wire protocol major/minor | | `iconUrl`, `bannerUrl` | Content-addressed image URLs the master serves; both may be null | | `startedAtUtc`, `lastHeartbeatAtUtc` | Uptime, and proof you are still there | Use the stable public `id` in server links; `connectEndpoint` can change when the server moves. Directory metadata provides the server name, description, branding, locale, tags, player counts and compatibility fields. Listing status depends on heartbeats received by the master, not a ping probe from the launcher. The master does not publish client-to-server latency. **The launcher does not enforce your `expectedGameBuild` or `protocol`.** It carries them and does not check a joining player against them. Version matching is settled between the client and your server, not filtered in the browser — so do not expect the launcher to hide you from a player on the wrong build. ## Deep links: the one contract you can rely on A link that boots a player straight into your world is a single URL shape: ```text open77://connect?server= ``` with an optional `&endpoint=` fallback. `` is the master's `id` for your server — not your endpoint, not your name, not a slug you chose. This is the same URL the OPEN//77 website's own Join buttons emit, so a link you put in your Discord behaves identically to one on [the server browser](/servers). Put it behind a button, in a pinned message, on your own site — the contract does not care where it came from. The launcher also accepts `open77:connect?…` with no double slash, and takes the `endpoint` parameter as a fallback when the id cannot be resolved — a target reached that way is labelled as a direct connect rather than by your server's name. If neither resolves, the launcher reports that the server is not in the catalogue instead of dialling something. **Both values are sanitised before anything is done with them.** A value longer than 128 characters is rejected, and only letters, digits and `. : - _ [ ]` are allowed. Anything outside that alphabet causes the whole value to be **dropped, not cleaned up** — deliberately, because a partly-stripped id would connect someone to the wrong world in silence. Real ids and `host:port` endpoints fit inside that alphabet; a link that has been through a URL shortener's tracking parameters or a chat client's rewriting may not. ### What happens when someone clicks it The launcher registers the `open77` scheme per-user under `HKCU\Software\Classes\open77`, idempotently and self-healing on every start, so the OS routes the URL to it. From there: - **If the launcher is closed**, it opens and holds the target. - **If the launcher is already open**, the URL is forwarded to the running window over a named pipe and that window comes to the foreground. A second window is never opened. - **If the player is signed out**, or the game is not yet verified, or the mod set is not up to date, the target is *queued*. They sign in and get set up, and the connect fires on its own once the launcher reaches its ready state. They do not have to click your link twice. - **If the player has no launcher installed**, the browser shows its usual "no handler for this scheme" prompt and nothing happens. Navigating to an unhandled scheme never unloads the page they were on, so a Join button is safe to offer unconditionally — but it is also silent, which is worth a line of copy next to the button. ### How the id becomes a connection The launcher resolves your `id` against the master directory it already holds, takes your `connectEndpoint`, and sets `OP77_CONNECT=` (alongside `OP77_CONNECT_MASTER=`) in the game process environment before starting Cyberpunk 2077. The client plugin reads `OP77_CONNECT` at init and, once the shell is eligible, drives **the same authenticated autoconnect a manual click takes** — resolve, ticket, dial. That last clause is the important one for you: **a deep link does not bypass ticketing**. It is a shortcut through the launcher's own UI, not through the platform's authentication. A player arriving via a link is authenticated exactly as one who browsed to you, and your ACL and ban list see no difference. If a client is already running, it is joined in place rather than relaunched. Resolution happens launcher-side because the client dials by endpoint and learns your server's GUID from the handshake in order to fetch its ticket — there is no id-to-endpoint resolver inside the game. That is why the endpoint, not the id, is what crosses into the game process. On any failure the shell falls back to the normal server browser carrying the error. It does not hang, and it does not sit on a black screen. ## What a connecting player must have Nothing you can relax, and nothing you have to distribute: - **Their own legal copy of Cyberpunk 2077**, at the exact game build the platform targets, with the Phantom Liberty expansion. The client hooks the engine at addresses fixed for that build and refuses to load on a mismatch, so this is an equality, not a floor. See [How the platform works](/docs/platform) for the current build. - **The OPEN//77 client mod**, which the launcher installs and keeps current on its own. You do not ship it, host it, or version it. - **A platform account**, signed in through the launcher. - **Windows.** The launcher is a Windows desktop application. Your server needs none of this. It never loads game content and never needs the game installed — see [Host your own server](/docs/host-a-server). The launcher also checks the player's game hash against the master's build manifest before launching, and warns on an unknown, deprecated or revoked build. That check is **advisory**: it is a warning at the launcher, and the real enforcement happens at ticket time. Do not treat a launcher warning as the thing keeping wrong builds off your server; the ticket is. ## Required mods and your server The launcher manages a signed index of packages, a mandatory floor a player cannot untick, optional packages, declared dependencies, path-conflict refusal, declared overrides, and a redscript preflight that refuses to launch a set that does not compile. [The OPEN//77 launcher](launcher.md#the-mod-menu) documents it from the player's side. **What your server can add to it:** a `requiredMods` list. A world declares the packages every player must load, **hosts those bytes itself**, and the launcher resolves the whole set on the Connect click — before the game process exists, so a normal join needs no restart. OPEN//77 never holds a third-party mod: the master vouches for a SHA-256, not for a file. Required mods follow these rules: - **Verified / unverified / blocked.** An unreviewed package is not blocked — the player is told plainly that your world supplied it and OPEN//77 has not checked it, and they decide. - **The capability cap.** An unverified package may carry inert data only (`.archive`, `.tweak`, `.xl`, `engine/config` INIs). Anything the game or the OS would execute — `.dll`, `.reds`, CET Lua, `.asi` — is refused until those exact bytes are reviewed. There is no player toggle that lifts this. - **Redistribution is a separate question from safety.** A mod can be perfectly sound and still be one its author never licensed you to hand out. Where that is the case the launcher asks the player to fetch it from the author's own page instead. [Mods your server requires](/docs/server-mods) is the full guide: importing from the Warden panel, who hosts what, bandwidth, and removing a mod without stranding players. For content the base game can already render — props, markers, zones, a course built from existing assets — **[server resources](/docs/server-resources)** remain the better route: signed Lua and assets your server hands to clients on connect, no download of anything else and no restart. Reach for a required mod only when the content genuinely is not in the game. ## Things that will bite you | Symptom | Cause | |---|---| | Your world never appears in the launcher | You are not in the master directory. Check registration and your license key before looking anywhere else | | It appeared, then vanished after you changed images | An over-budget icon/banner pair fails the whole registration, not just the picture — see [Branding your server](server-branding.md) | | Your Join link does nothing on some players' machines | They have no launcher installed; the OS silently drops the scheme | | A player says the link "went to the browser, not my server" | The target was queued behind sign-in or a mod update and fired once the launcher was ready — or it failed and fell back to the browser with an error they clicked past | | You filed yourself under a mode nobody browses | The mode chip comes from the **first** of your `tags` that names a known mode; order matters | | You publish your endpoint and it stops working | Publish the `id`. The endpoint is not the public identifier | ## See also - [The OPEN//77 launcher](launcher.md) — the same machinery from the player's side. - [Host your own server](/docs/host-a-server) — getting a server running at all. - [Server licensing](/docs/server-licensing) — the key that lets you register. - [Branding your server](server-branding.md) — the icon and banner on your row. - [Server resources](/docs/server-resources) — how to actually ship content to your players. --- Source: https://open2077.net/docs/server-branding # Branding your server Set a square server icon and a wide banner for the launcher and website. Both are optional; servers without images use a placeholder. Upload images from Warden. Icon changes do not require a server restart. For the in-game Escape menu, use [Pause menu customization](pause-menu.md). Its Lua accent and resource logo are separate from the launcher icon and website banner. ## What the two images are | | Icon | Banner | |---|---|---| | Where it appears | server row in the launcher, server card and detail page on the site | hero strip above the server name on its detail page | | Shape | square | wide | | Format | PNG only | PNG, JPEG or WebP | | Recommended | **256×256** | **1200×400** (3:1) | They are files that live next to your `server.jsonc`, referenced by `identity.icon` and `identity.banner` as **relative paths** — not URLs. Your server reads the bytes, sends them to the master inside its registration, and the master stores them by content hash and serves them from `/api/v1/icons/{sha256}` and `/api/v1/banners/{sha256}`. You never upload anything to a CDN, and there is nothing to host yourself. ## The rules that actually apply Several guards sit between your file and a picture on a page, and they are not all equally strict. The bounds below are the **intersection** — an image that satisfies them works everywhere. Warden enforces exactly this list, so if the panel accepts your file, the website will draw it. ### Icon | Rule | Value | |---|---| | Format | PNG | | Size | **64×64 to 512×512** pixels | | Shape | square (the site hides any icon more lopsided than 1.2:1) | | File size | at most 256 KiB | The 64 px floor is the one that catches people. A 32×32 icon is accepted by an older server build and by the master, and then silently shows a placeholder on the website. Warden refuses it up front and tells you the size you gave it. ### Banner | Rule | Value | |---|---| | Format | PNG, JPEG or WebP | | Width | 320 to 4096 pixels | | Aspect ratio | between 2.0:1 and 5.0:1 | | File size | at most 2 MiB | ### The one that is easy to miss: the pair has a combined budget Both images travel base64-encoded inside a **single registration request** to the master, and that request is capped at 512 KiB. So the two files together must stay under about **378 KiB of raw bytes** — well below the 2 MiB a banner is allowed on its own. This matters more than it looks. Going over does not produce a missing image: the whole registration is refused, and **your server does not appear in the server list at all**. Warden checks the pair before it writes anything and tells you how much to shave off; a server started with an over-budget pair already on disk logs an explicit error instead of failing silently. A PNG exported at a sensible palette is nowhere near this. A photographic banner saved as a 24-bit PNG easily is — export it as JPEG or WebP. ## Uploading from Warden 1. Open your panel and sign in (see [Warden](/docs/warden) if you have not set it up yet). You need the `config.edit` permission — the owner account has it. 2. Go to **Config**. Under *Identity* there is a picker for the icon and one for the banner, each with the rules printed next to it and a preview of what is currently set. 3. Choose a file. The panel decodes it in your browser and checks it immediately, so a wrong size is refused before anything is uploaded — with the bound and your file's actual dimensions in the message, for example *"Icon must be at least 64×64 px; yours is 48×48."* 4. Under the two pickers, a line shows how much of the combined budget you are using. 5. Press **Apply changes**. Your server writes the file next to its config (`server-icon.png`, `server-banner.png`) and updates `server.jsonc` to point at it. ## What happens next, and what needs a restart **Images do not need a restart.** Applying them re-sends your server's registration to the master straight away, with the new pictures in it. The panel tells you what happened — *"images written; published to the master"* — and the launcher and website pick the change up on their next refresh, within about a minute. Everything else in that form — name, description, tags, visibility, player cap, public endpoint — is baked into the options your server started with, and those **do** need a restart. Warden only shows the "restart required" banner when a restart is genuinely required, so when you see it, it means something. To remove an image, use **Remove icon** / **Remove banner** and apply. That clears the reference; the file stays on disk. ## Doing it by hand instead Warden is not required. Put the files next to `server.jsonc` and name them in the identity block: ```jsonc "identity": { "name": "My Server", "icon": "server-icon.png", "banner": "server-banner.png" } ``` The paths are relative to the config file and may not escape its directory or be symlinks. Restart the server to pick them up. If an image is outside the bounds above, the server still starts and logs a warning saying which bound it missed — worth reading, because that is the difference between a picture and a placeholder. ## Banner support needs a recent master Banners are newer than icons. A master that predates them accepts the registration and stores the icon, but its catalogue response carries no banner field, so nothing shows the banner anywhere — no error, just no hero image. If your icon appears and your banner does not, and everything above checks out, this is almost certainly why. Icons are unaffected. ## When something does not show up | Symptom | Likely cause | |---|---| | Placeholder gradient instead of your icon | icon under 64 px, or not square — the site's display guard hides it silently | | Server missing from the list entirely, right after a branding change | the icon and banner together exceed the master's registration limit; check the server log for a 413 | | Icon shows, banner does not | the master is older than banner support (see above) | | Nothing changed after Apply | check the panel's message — an error is shown in full, and a "restart required" banner means the field you edited was not an image | --- Source: https://open2077.net/docs/rcon # RCON: authenticated TCP administration Use Open77 RCON to build a hosting panel, dashboard, bot or command-line tool that controls a dedicated server. It exposes Warden's administrative operations over a separate TCP connection: players, console, resources, configuration, permissions, Workshop jobs, live logs and performance captures. This page describes **Open77 RCON protocol v1**. Use a server build that supports the `rcon` configuration section. The protocol borrows the authenticated, persistent TCP session model from Minecraft RCON, but uses its own wire format. Minecraft/Source RCON clients such as `mcrcon` are not compatible. ## Enable RCON Complete [server setup](/docs/host-a-server) first. Add this root section to `server.jsonc` and restart the server: ```json "rcon": { "enabled": true, "bindAddress": "127.0.0.1", "port": 11782, "passwordEnvironmentVariable": "OP77_RCON_PASSWORD", "permissions": ["*"] } ``` Set `OP77_RCON_PASSWORD` in the server process environment to a randomly generated secret of 16–1024 characters. Inject it through your hosting panel's secret store or a protected service environment file. Do not put it in JSON, Git, a URL or a command-line argument. RCON is disabled by default. Its default TCP port is `11782`, separate from the game's UDP port and resource downloads. `warden.enabled` can remain `false`: the shared administration backend runs without opening Warden's HTTP listener. The server package's `tools/rcon/client.mjs` and `tools/rcon/cli.mjs` provide a dependency-free Node.js client. With Node.js **22 or later** and the password set in your shell environment: ```bash node tools/rcon/cli.mjs command status node tools/rcon/cli.mjs request GET /api/dashboard node tools/rcon/cli.mjs discover node tools/rcon/cli.mjs logs ``` ## Connect remotely For a dashboard on another machine, keep the listener on loopback and use an SSH tunnel, or enable TLS on a remote bind address. ```bash ssh -N -L 11782:127.0.0.1:11782 operator@your-server ``` For direct TLS connections: ```json "rcon": { "enabled": true, "bindAddress": "0.0.0.0", "port": 11782, "tlsCertificateFile": "private/rcon.pfx", "tlsCertificatePasswordEnvironmentVariable": "OP77_RCON_CERT_PASSWORD", "permissions": ["dashboard.view", "players.view", "console.view"] } ``` The PFX/PKCS#12 file must contain a private key. Relative paths resolve against the configuration directory. Supply its password through the named environment variable. TLS starts immediately on connection; there is no STARTTLS exchange. TLS 1.2 and 1.3 are supported. Clients must trust the certificate and verify its hostname. Restrict the port in your firewall to the dashboard hosts. Plaintext non-loopback listeners require explicit `allowInsecureRemote:true`. Use that option only behind a trusted encrypted tunnel or on an isolated network: the password and administrative data otherwise travel unencrypted. Authentication and permissions remain enforced. The CLI accepts `OP77_RCON_HOST`, `OP77_RCON_PORT`, `OP77_RCON_USERNAME`, `OP77_RCON_TLS=1`, `OP77_RCON_CA_FILE` and `OP77_RCON_TLS_NAME`. Use a PEM CA file for private PKI instead of disabling certificate verification. ## Authentication and permissions Omit `username` in the authentication frame to use `$service`. This identity gets the permissions listed in `rcon.permissions`. Set `allowWardenAccounts:true` to also accept existing Warden operator usernames and passwords. These are server-local Warden accounts, not website accounts, master API keys or in-game ACL identities. Named accounts keep their Warden role; the service permission list does not replace it. You can omit the service password only when named accounts are enabled and at least one operator already exists. Operations use the same permission checks, validation, confirmations and audit as Warden. Audit attribution is `rcon:` or `rcon:$service`. Passwords and session tokens are not included in authentication logs. Console commands and administrative details remain auditable; do not include secrets in command text. Deleting an account invalidates its existing connection, even if the name is recreated. Role/permission changes close active sessions within one second. Queued game-loop work checks authority again before execution. Reauthenticate after changing roles. Changing the service password requires a server restart. `*`, console execution, resource upload and account management are powerful administrative capabilities. Give each integration only the permissions it needs. For example, a read-only panel can use `dashboard.view` and `players.view`. ## Configuration reference All fields are under `rcon` and take effect at server startup. | Field | Default | Purpose / range | | --- | --- | --- | | `enabled` | `false` | Enable the listener. | | `bindAddress` | `127.0.0.1` | IPv4/IPv6 literal, not a hostname. | | `port` | `11782` | TCP port, 1–65535. | | `passwordEnvironmentVariable` | `OP77_RCON_PASSWORD` | Name of the environment variable holding the service password. | | `allowWardenAccounts` | `false` | Allow existing named Warden operators. | | `permissions` | `["*"]` | Service permissions, up to 128 names. `[]` grants no administrative operations. | | `allowInsecureRemote` | `false` | Explicitly allow plaintext non-loopback TCP. | | `tlsCertificateFile` | `null` | PFX file with private key. | | `tlsCertificatePasswordEnvironmentVariable` | `OP77_RCON_CERT_PASSWORD` | Variable holding the PFX password; optional for an unencrypted PFX. | | `maxConnections` | `32` | Concurrent sockets, authenticated or not; 1–256. | | `maxConnectionsPerIp` | `8` | Per-source-IP sockets; 1–`maxConnections`. | | `maxFrameBytes` | `1048576` | Authenticated incoming JSON payload cap; 65536–4194304 bytes. | | `maxInFlightRequests` | `8` | Active commands/requests per connection, including log streams; 1–32. | | `maxRequestsPerSecond` | `30` | Client operations per second, including pings and upload chunks; 1–1000. | | `authenticationTimeoutSeconds` | `10` | TLS handshake and authentication deadline; 1–60. | | `requestTimeoutSeconds` | `60` | Cooperative deadline for ordinary operations; 1–600. | | `idleTimeoutSeconds` | `600` | Deadline for the next complete client frame; 10–86400. | | `sessionLifetimeSeconds` | `3600` | Absolute session lifetime, including reconnects; 10–86400. | | `maxUploadBytes` | `67108864` | One staged upload per connection; 1–1073741824 bytes. Endpoint body caps still apply. | [Startup overrides](/docs/server-startup) work for this section: ```bash ./Open77.Server --rcon.enabled=true --rcon.port=12782 ``` Equivalent environment overrides include `OP77_CONFIG__RCON__ENABLED=true` and `OP77_CONFIG__RCON__PORT=12782`. CLI values override environment configuration, which overrides the file. `OP77_RCON_PASSWORD` is a credential, not a configuration override. `--check-config` validates fields and transport policy without listening; credentials, certificate availability and port binding are checked at startup. ## Build a dashboard backend Browser JavaScript cannot open a raw TCP socket. Connect from your backend and keep credentials and resume tokens there. Your web API still needs authentication, authorization, CSRF protection and an allowlist of actions. Do not expose an unrestricted HTTP-to-RCON proxy to players. ```js import { RconClient } from './tools/rcon/client.mjs'; const rcon = await RconClient.connect({ host: '127.0.0.1', port: 11782, password: process.env.OP77_RCON_PASSWORD, }); try { const dashboard = await rcon.json('GET', '/api/dashboard'); const players = await rcon.json('GET', '/api/players'); console.log({ dashboard, players }); await rcon.json('POST', '/api/announce', { reason: 'Restart in five minutes.', }); } finally { rcon.close(); } ``` For TLS, pass `tls:{ca, servername}` to `connect()`; `ca` is PEM data, not a path. Omit `ca` to use system trust. The helper refuses `rejectUnauthorized:false`. `request()` and `command()` return `{status, contentType, headers, bytes, body, json?}`. `json()` additionally throws for error statuses and `ok:false`. Protocol errors throw `RconError` with a `code`. The helper handles framing, correlation, request pacing and heartbeats; it does not automatically reconnect or replay mutations. ## TCP framing Every frame contains: | Bytes | Value | | --- | --- | | 0–3 | Unsigned 32-bit **big-endian** JSON payload byte length. | | 4 onward | One UTF-8 JSON object of exactly that length. | The length excludes the prefix. Do not append a NUL, newline, BOM or other terminator. TCP reads may contain partial frames or multiple frames; buffer by length, not by socket-read boundaries. JSON names and operation names are case-sensitive. Nested JSON is limited to 32 levels; duplicate properties and malformed/oversized frames close the connection. Before authentication, the payload cap is 16384 bytes. The server sends a greeting before expecting credentials: ```json {"id":0,"type":"hello","protocol":"open77-rcon","version":1,"maxFrameBytes":1048576,"maxInFlightRequests":8,"maxUploadBytes":67108864,"maxRequestsPerSecond":30,"idleTimeoutSeconds":600} ``` Client request IDs are positive integers up to `9007199254740991`, strictly increasing within one connection. Responses for different IDs can interleave; frames for a given ID stay ordered. IDs reset after reconnect and are not durable idempotency keys. ### Authenticate ```json {"id":1,"op":"auth","version":1,"password":""} ``` Optional fields are `username` and `sessionToken`. Success returns: ```json {"id":1,"type":"result","ok":true,"username":"$service","permissions":["*"],"sessionToken":"rcon:","expiresAtUtc":"2026-09-23T18:00:00+00:00"} ``` ### Operations | `op` | Required fields | Response | | --- | --- | --- | | `ping` | None | `result` with `ok:true`. | | `discover` | None | `result` with `protocol` and `operations:[{method,path},…]`. | | `command` | `command` | Streamed console-command result. | | `request` | `method`, `path` | Streamed administrative result; optional JSON `body` **or** `uploadId`, and `contentType`. | | `cancel` | `requestId` | `result` with `cancelled` and the target ID. | | `upload.begin` | `bytes`, hexadecimal `sha256` | `result` with `uploadId`. | | `upload.chunk` | `uploadId`, byte `offset`, base64 `data` | `result` with cumulative `bytes`. | | `upload.finish` | `uploadId` | `result` with `ready:true` after verification. | | `upload.abort` | `uploadId` | `result` with `ok:true`. | Request methods are `GET`, `POST`, `PUT`, `PATCH` and `DELETE`. The path is a relative `/api/...` target with optional query string, not an external URL. `contentType` defaults to `application/json`. Arbitrary cookies, authorization headers and origin headers are not accepted. ```json {"id":2,"op":"request","method":"GET","path":"/api/players"} {"id":3,"op":"command","command":"status"} {"id":4,"op":"request","method":"POST","path":"/api/restart","body":{"delaySeconds":60,"reason":"Maintenance"}} ``` ### Read a response Administrative requests return metadata, zero or more body chunks, and a terminal frame. This example's base64 data decodes to `[]`: ```json {"id":2,"type":"response","status":200,"contentType":"application/json; charset=utf-8","headers":{}} {"id":2,"type":"data","data":"W10="} {"id":2,"type":"end","complete":true,"bytes":2,"error":null} ``` Decode and concatenate bytes before decoding UTF-8 or parsing JSON: a character can span chunks. Each data chunk contains at most 32768 decoded bytes. `bytes` counts all decoded body bytes. Selected headers preserve download metadata: `Content-Disposition`, `Content-Range`, `ETag` and `Retry-After`. `complete:true` means processing finished, not that the operation succeeded. Check `status` and the body's `ok`, `error` or job state. A 202 means an accepted asynchronous job; poll it. A 200 with `ok:false` is an application refusal. `complete:false` or EOF without a terminal frame is not success, even if the initial status was 200. Protocol-level refusals can instead return: ```json {"id":5,"type":"error","error":{"code":"busy","message":"The session already has the maximum number of active requests."}} ``` ## Administrative endpoints Use `discover` to read the actual route catalogue of the connected server build. Replace placeholders such as `{id:guid}` with values and URL-encode path segments. Discovery does not grant permission or guarantee that a subsystem is ready. | Area | Endpoint examples | Permissions | | --- | --- | --- | | Server status | `GET /api/dashboard` | `dashboard.view` | | Players | `GET /api/players`; `POST /api/players/{id}/kick`, `/ban`, `/warn`, `/heal`, `/freeze`, `/unfreeze`, `/teleport`, `/bring` | `players.view` and the corresponding action permission. | | Announcements/restarts | `POST /api/announce`; `POST`/`DELETE /api/restart` | `announce.send`, `restart.schedule` | | Resources | `/api/resources`, `/api/resources/{name}/{action}`, `/validate`, `/files` | `resources.view`, `resources.control`, `resources.upload` | | Console/logs | `/api/console/command`, `/api/console/log`, `/api/console/stream` | `console.execute`, `console.view` | | Configuration/branding | `/api/config`, `/api/config/icon`, `/api/config/banner` | `config.view`, `config.edit` | | In-game ACL | `/api/acl`, `/api/acl/roles`, `/api/acl/identities` | `acl.view`, `acl.edit`; grant restrictions still apply. | | Whitelist/bans | `/api/access`, `/api/access/whitelist`, `/api/access/bans/{userId}` | `access.view`, `access.edit` | | Tunables | `/api/tunables`, `/api/tunables/{resource}/keys/{key}`, `/promote` | `tunables.view`, `tunables.edit` | | Required mods | `/api/mods`, `/api/mods/inspect`, mod removal/review routes | `mods.manage` | | Operator accounts | `/api/users`, `/api/users/{name}/role`, `/api/roles` | `users.manage` | | Audit | `GET /api/audit` | `audit.view` | | Workshop | `/api/hub/*`: catalogue, plans, jobs, installs, rollback, retention, exports and publishing | `hub.view`, `hub.manage`, `hub.publish` | | Performance | `/api/performance/*`: live metrics, capture control and ZIP exports | `performance.view`, `performance.control` | | Full reset | `POST /api/reset/arm`, `POST /api/reset` | `server.reset`, plus console PIN and typed-name confirmation. | The request bodies and results are Warden's contracts. For example: ```js await rcon.json('POST', '/api/players/7/kick', { reason: 'AFK' }); await rcon.json('POST', '/api/resources/my_resource/restart', {}); await rcon.json('POST', '/api/access/whitelist', { enabled: true }); await rcon.json('POST', '/api/config', { name: 'My server', maximumPlayers: 32 }); await rcon.json('POST', '/api/tunables/my_resource/keys/speed', { value: 5 }); ``` RCON does not provide arbitrary shell access. Editable configuration fields, restart requirements and destructive-operation confirmations remain the same as Warden. Browser login/setup, provisioning, HTML and `/health` are not RCON operations. A stopped process cannot receive commands; restarting it requires your hosting supervisor or service manager. ## Live logs and file transfers `GET /api/console/stream?after=` returns UTF-8 SSE inside data frames: `data: {log JSON}\n\n`. Parse across chunk boundaries. Logs contain `seq`, `ts`, `level` and `message`. The recent/live feed is bounded, so sequence gaps can occur under load. Keep sending periodic pings even when only receiving logs. ```js const subscription = rcon.logs(entry => console.log(entry.message)); // Later, when this view closes: await subscription.cancel(); await subscription.result; ``` The helper awaits log/data callbacks for backpressure. Do not await another request on the same connection inside one of those callbacks. Use a separate connection if slow log consumers must not delay administrative commands. Uploads are connection-owned and limited to one staged file at a time. Declare size/hash, append ordered chunks at the next exact offset, then finish. A verified upload can replace the body of one request; it cannot select an arbitrary server path. Abort or disconnect removes its temporary file. ```js const uploadId = await rcon.uploadFile('./vehicle-pack.zip'); const inspection = await rcon.call('request', { method: 'POST', path: '/api/mods/inspect?fileName=vehicle-pack.zip', uploadId, contentType: 'application/octet-stream', }); console.log(inspection.status, inspection.json); ``` Inspection does not install the mod. Re-upload for a second operation. Endpoint body limits remain enforced: ordinary admin bodies are capped at 4 MiB and required-mod archive bodies at 64 MiB, even if `maxUploadBytes` is higher. For large downloads, pass `onResponse` and `onData` to `request()`. Check status before writing, await file writes for backpressure, and promote the temporary file only after the result completes. Without a streaming callback, the helper buffers at most 16 MiB by default; `maxResponseBytes` can change that local limit. ## Session recovery and cancellation Reconnect with both credentials and the previous `sessionToken` to recover Workshop plan/job/export ownership. Tokens are identity-bound, expire at `expiresAtUtc` and do not survive a server restart. Resuming does not extend the original expiry, recover unfinished command results, or restore uploads/log subscriptions. Keep tokens in backend memory or protected storage. Cancellation prevents queued game-loop work from beginning but cannot undo an operation already committed. An accepted Workshop job has its own lifetime: cancelling its request or disconnecting does not cancel the job. Use the job's explicit cancellation endpoint and poll its state after reconnecting. After a timeout or lost connection, inspect current state before retrying a mutation. Do not automatically replay install, publish, grant, reset or player actions. The client helper's `start()` returns `{id, result, cancel}` when you need explicit request cancellation. ## Errors and troubleshooting | Code / symptom | What to check | | --- | --- | | Connection refused | Listener enabled, TCP port, bind address, firewall, connection cap and auth cooldown. | | `authentication_required` | Send an `auth` frame first. | | `authentication_failed` | Credentials, named-account opt-in, and cooldown. | | `unsupported_version` | Connect using Open77 RCON v1, not Minecraft/Source framing. | | `session_unavailable` | Token expired, belongs to another identity, or retained-session capacity reached. | | `session_revoked` | Operator was removed or lost authority; reauthenticate with current rights. | | `busy` | Too many active requests on this connection; long-lived log streams count. | | `rate_limited` | Reduce polling/upload frame rate before reconnecting. | | `invalid_request` | Operation name, field types, path, upload offset/hash/state, or conflicting bodies. | | `payload_too_large` | Transport upload limit or the target endpoint's body cap. | | `cancelled_or_timed_out` | Check current server/job state before retrying. | | `operation_failed` | Server diagnostics and subsystem state. | | 403 with a permission name | The authenticated role lacks that operation's permission. | | TLS failure | PFX private key, trusted certificate chain, hostname and matching TLS configuration. | Five failed password attempts from one IP cause a five-minute cooldown. Slow readers have a 15-second write deadline. Malformed frames, exhausted connection limits and expired deadlines can close the socket without an error frame. Startup logs identify the RCON bind address and TLS mode. Warden's audit remains available through `GET /api/audit` and `.open77/warden/audit.jsonl`. Debug logs show connection error types without echoing credentials or raw protocol payloads. Related guides: [Warden](/docs/warden), [player administration](/docs/warden-players), [Workshop administration](/docs/community-hub-warden), [connection control](/docs/connection-control), [permissions](/docs/server-acl), [Prometheus metrics](/docs/metrics), and [startup arguments and logging](/docs/server-startup). --- Source: https://open2077.net/docs/metrics # Prometheus metrics Monitor server load, player activity and resource costs over time with Prometheus, then visualize the collected history in Grafana. The dedicated server exposes a read-only `GET /metrics` endpoint in Prometheus text exposition format 0.0.4. Collection runs continuously without a Warden browser, RCON connection, active performance capture or client update. Gamemodes can add their own counters through the server-side Lua API. ## Enable the endpoint Complete [server setup](/docs/host-a-server) and use a server build that supports the `metrics` configuration section. Prometheus must be able to reach the metrics listener; it does not use the game's UDP port. Add this root section to your existing `server.jsonc`, then restart the server: ```json "metrics": { "enabled": true, "listenUrl": "http://127.0.0.1:11783", "tokenEnvironmentVariable": "OP77_METRICS_TOKEN" } ``` Metrics are **disabled by default**. The default listener is loopback-only and independent of the game UDP port, resource download HTTP server, Warden and RCON. Assign a different metrics port to each server instance on the same host. `HEAD /metrics` returns the same headers without the body. Other routes return 404; unsupported methods return 405. No endpoint can run commands or modify state. Check locally: ```bash curl --fail http://127.0.0.1:11783/metrics ``` ### Configuration reference | Field | Default | Purpose | | --- | --- | --- | | `enabled` | `false` | Start the read-only HTTP listener. Requires a restart. | | `listenUrl` | `http://127.0.0.1:11783` | One HTTP origin using an IP literal or localhost. | | `tokenEnvironmentVariable` | `OP77_METRICS_TOKEN` | Name of the environment variable containing the secret, not the secret itself. | ### Authentication and remote access Set the environment variable named by `tokenEnvironmentVariable` to a randomly generated secret of 32–256 printable ASCII characters, with no spaces. Restart the service so it receives that environment. Do not put the secret in the JSON configuration, a URL, version control or a dashboard screenshot. When a token is configured, **every** scrape requires `Authorization: Bearer ...`, including loopback scrapes. A non-loopback bind such as `http://0.0.0.0:11783` **refuses startup without a token**. Hostnames other than `localhost`, URL paths, credentials, query strings and multiple listener URLs are rejected. The built-in listener uses HTTP. Bearer authentication is not encryption: keep it on loopback behind a TLS reverse proxy, or use a trusted private network/tunnel. Do not expose the plaintext listener to the public Internet. Apply firewall rules allowing only your collector. The token grants metrics access only, not administration. ## Prometheus configuration For a collector on the same host, without an environment token: ```yaml scrape_configs: - job_name: open77 scrape_interval: 5s scrape_timeout: 3s metrics_path: /metrics static_configs: - targets: ['127.0.0.1:11783'] labels: server: freeroam-german channel: unstable ``` If a token is configured, add this to the job. The file contains only the token and must be readable only by the Prometheus service account: ```yaml authorization: type: Bearer credentials_file: /etc/prometheus/open77-metrics-token ``` For a TLS reverse proxy, use `scheme: https` and its hostname/port in `targets`. Container loopback is the container itself: route to the server through a private network instead of assuming `127.0.0.1` reaches the host. Prometheus stores the history; Grafana can visualize it with a Prometheus data source. This endpoint does not persist a time series or replace the existing [Warden](/docs/warden) performance captures, which remain available. ### Graphs for a stress test Start the collector before players connect and keep it running through the test and the return to an idle server. Prometheus stores the history; the server endpoint only exposes current values and cumulative counters. In Grafana, use your Prometheus data source and the [queries below](#useful-queries). Put connected/ready players alongside tick p95/p99, CPU core equivalents, resident memory, GC pauses, outbound bandwidth and queue ages. Keep the same time range and server filter across panels so spikes can be compared. There is no built-in dashboard provisioned by enabling this endpoint. Choose the collector's retention and storage for the length of the test. Warden captures remain a separate way to export detailed performance recordings. ## Collection and interpretation - The simulation publishes measurements about once per second. Every completed window contributes to counters, even when Warden skips an intermediate window. - Process/GC sampling and text rendering run on background workers, also at 1 Hz. HTTP serves cached bytes and does not call Lua, enumerate entities, query SQL or poll the transport. Scraping faster than 1 Hz adds no useful resolution. - Counters ending in `_total` accumulate until process restart unless otherwise described. Use `rate()`/`increase()`, which handle resets. Scrapes never drain them. - Gauges describe current state or the latest window. Missing optional diagnostics mean **unavailable**, not zero. Check availability and timestamp metrics. - Tick, lateness and database histograms accumulate exact disjoint observations into cumulative buckets, including `+Inf`. `*_window_seconds{statistic=...}` contains the existing latest-window approximate percentiles, not Prometheus histogram quantiles; never average those percentiles across servers. - Histogram bounds in seconds are: `0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.016667, 0.033334, 0.05, 0.1, 0.25, 0.5, 1, 5, 10, +Inf`. Above the final finite bucket, use sum/count and latest-window max as well. ## Exported families Every family includes `HELP` and `TYPE` lines. The endpoint is the complete field-level inventory; optional families appear when their data source exists. | Domain | Metrics / meaning | |---|---| | Build and capacity | `open77_build_info`, `process_start_time_seconds`, `open77_uptime_seconds`, `open77_players_capacity`, configured tick/snapshot rates | | Connections | `open77_connections`, `open77_admissions_pending`, `open77_players_connected`, `open77_players_ready`, joins/departures and connection rejections by bounded protocol reason | | Gameplay | `open77_players_life_state{phase}`, life transitions, deaths by cause, attributed kills, completed respawn/revive recoveries, accepted combat hits and damage by attack kind | | World | `open77_entities{kind}` for vehicles, NPCs, props, effects, loot, elevators and destruction records; creation/removal counters for NPCs, vehicles, props and effects | | Visibility | `open77_routing_buckets_occupied`, `open77_interest_relationships`; no bucket or player identifiers | | Vehicles | Owned/unowned, moving, destroyed, stale motion, active simulators, maximum per-simulator vehicle count, AI jobs/states, authority changes, rejected NPC/vehicle motion | | Tick | `open77_tick_duration_seconds`, `open77_tick_lateness_duration_seconds`, over-budget counters, latest-window mean/p50/p95/p99/max and window duration | | Process CPU/RAM | `process_cpu_seconds_total`, `process_resident_memory_bytes`, `open77_process_cpu_cores`, `open77_process_cpu_ratio`, logical processors and threads | | .NET | `open77_dotnet_heap_bytes`, GC committed bytes, allocated bytes total/rate, GC pause seconds, collections by generation, thread-pool threads and pending work | | Linux host CPU | `open77_host_cpu_busy_ratio{cpu}`, `open77_host_cpu_steal_ratio`, when `/proc/stat` can be read | | Lua resources | Resource states, running flag, VM memory, reload generation, scheduler/event wall time, scheduler invocations, max duration, failures and slow ticks | | Replication | `open77_replication_stage_{calls,items,seconds,allocated_bytes}_total{stage}` and network poll wall time | | Transport | `open77_transport_*`: application bytes/messages, send failures, current native connection diagnostics, latency/quality where available, pending native queues and throughput estimates | | Protocol | `open77_transport_message_bytes_total{kind,direction}` identifies bandwidth-heavy protocol kinds | | Send scheduling | All numeric `open77_transport_send_queue_*` and `open77_transport_timed_queue_*` diagnostics: pending frames/bytes, oldest ages, attempts, sends, budget skips, coalescing/drops and backlog | | Native lanes | `open77_transport_lane_*{lane}`: numeric lane diagnostics over current connections. **Gauges**, even for native accepted-byte totals, because disconnecting a peer removes its contribution | | Net events | `open77_net_events_total`, `open77_net_event_messages_total`, `open77_net_event_bytes_total`; outbound resource/event attribution and aggregated inbound traffic | | Voice | All numeric `open77_voice_*` router diagnostics, including active talkers/participants/channels and cumulative routing/drop counters | | Database | Enabled/reachable, latest probe and success timestamps, submitted/completed/failed operations, queued/executing requests, completion backlog, returned rows, truncated results, duration histogram by operation | | Health | `open77_loop_stale`, loop/sample/render timestamps, sampler missed intervals/skipped windows, warning/error log totals, exporter render duration/response size and HTTP 200/401 counters | Transport and voice record fields use snake_case. Milliseconds become seconds, percentages become ratios, and lifetime counter fields receive `_total`. `open77_transport_metrics_available` distinguishes missing transport diagnostics. ### Important boundaries CPU in `open77_process_cpu_cores` uses **one fully busy logical core = 1**; the normalized `open77_process_cpu_ratio` divides by the process's available logical processors. Process RSS is not the machine's total RAM. Lua memory is VM-reported, not exclusive resident memory. Resource scheduler and event timings measure wall time, not CPU instructions; nested replication timings overlap and must not be summed. Network bytes are application/transport observations, **not** total NIC bandwidth including UDP/IP headers, retransmission overhead or a guarantee of delivered latency. Native quality and latency diagnostics depend on the transport backend. For disk space/I/O, whole-machine RAM, kernel UDP drops and NIC traffic, collect a host exporter alongside this endpoint. FPS, GPU use and client RAM cannot be inferred from server counters; those would require an explicit client-telemetry feature. Database request timings cover the **resource SQL bridge**, including thread-pool, connection and execution waits. They do not measure every internal persistence subsystem's SQL. No SQL, parameters or connector error messages are exported. `ready` means the resource readiness gate passed, not that a native visual frame was rendered. Kills follow authoritative death attribution; they are not a PvP leaderboard. Economic balances, race results and RP job outcomes are gamemode-specific: use the Lua API below rather than expecting the server to guess their meaning. ## Useful queries Filter by your `server`/`instance` label when selecting one instance. ```promql # Connected versus ready players open77_players_connected open77_players_ready # Tick p95 over five minutes, milliseconds histogram_quantile(0.95, sum by (instance, le) ( rate(open77_tick_duration_seconds_bucket[5m]) )) * 1000 # Actual observed tick rate and fraction of ticks over budget rate(open77_tick_duration_seconds_count[1m]) rate(open77_tick_over_budget_total[5m]) / clamp_min(rate(open77_tick_duration_seconds_count[5m]), 0.001) # CPU core equivalents; resident memory in MiB rate(process_cpu_seconds_total[5m]) process_resident_memory_bytes / 1024 / 1024 # Outbound application bandwidth in megabits/s; protocol cost rate(open77_transport_sent_bytes_total[1m]) * 8 / 1000000 topk(10, rate(open77_transport_message_bytes_total{direction="sent"}[5m])) # Most expensive resource schedulers in wall seconds/second topk(10, rate(open77_resource_scheduler_seconds_total[5m])) # GC pause share, deaths and database operation failure rate rate(open77_dotnet_gc_pause_seconds_total[5m]) sum by (instance) (increase(open77_player_deaths_total[5m])) rate(open77_database_operation_failures_total[5m]) # Detect cached-but-stalled data even while HTTP still answers time() - open77_loop_last_update_timestamp_seconds > 5 time() - open77_exporter_sample_timestamp_seconds > 5 time() - open77_exporter_render_timestamp_seconds > 5 up{job="open77"} == 0 ``` For capacity planning, compare equally long windows at different player counts: tick p95/p99, CPU, allocation/GC rate, bandwidth and queue ages together. A small average tick does not rule out tail stalls. Dividing total RSS by player count includes the fixed server baseline; it is **not** marginal RAM per extra player. Do not extrapolate one low-population measurement linearly to hundreds of players. ## Custom gameplay counters from Lua Server scripts can contribute bounded, resource-labelled metrics. Add: ```lua permissions { 'metrics.write' } ``` ```lua -- Server-side only. No-op failure when metrics is disabled: false, 'metrics_disabled'. local ok, reason = Open77.metrics.increment('shop_sales') Open77.metrics.increment('cash_spent', 250) Open77.metrics.gauge('race_participants', #participants) Open77.metrics.observe('job_run_duration', elapsedMilliseconds / 1000) ``` | Call | Export | |---|---| | `increment(name, amount = 1)` | `open77_gameplay__total{resource="your_resource"}` counter | | `gauge(name, value)` | `open77_gameplay_{resource="your_resource"}` current value | | `observe(name, seconds)` | `open77_gameplay__seconds{resource="your_resource"}` histogram family | All return `true` or `false, reason`. Names must match `[a-z][a-z0-9_]*`, be 1–64 characters and not end in `_total`, `_bucket`, `_sum`, `_count` or `_seconds`. A name's type is fixed across resources. Values must be finite, with absolute value at most `1e100`; counters and observations must be non-negative. Reasons include `permission_denied:metrics.write`, `metrics_disabled`, `invalid_metric_arguments`, `invalid_metric_name`, `invalid_metric_value`, `metric_type_conflict` and `metric_limit`. Limits are lifetime limits until server restart: 32 names per resource and 512 resource/name pairs globally. Counters/histograms survive resource restarts; current custom gauges reset on an explicit resource stop. Set gauges on startup and update them as gameplay changes. Do not emit metrics as a side effect of speculative hot-reload initialization: a discarded candidate's increments cannot be rolled back. Prefer runtime events after the resource has started. Never encode player names, IDs, IPs, email addresses, SQL, arbitrary item instances or timestamps into metric names. There are deliberately no caller-supplied labels. The resource label is supplied by the host, not by Lua. ## Cardinality and privacy Built-in labels use bounded enums/protocol kinds and resource names, not player identities. All inbound client event names are aggregated. Outbound event labels have a lifetime budget of 256; excess labels are aggregated. Named resource diagnostics also have a lifetime budget of 256, then use `resource="overflow"`; its timings/memory/counts aggregate, and no meaningless generation is emitted. `open77_resource_names_aggregated` reports the current overflow size. Removed resources no longer report running/memory usage; their cumulative counters remain. The registry has a final ceiling of 32,768 sample series. Treat the endpoint as operational data nonetheless: build versions, installed resource names, outgoing event names and load can reveal server internals. ## Validation and troubleshooting Use the Prometheus distribution's validator against the response: ```sh curl --fail --silent http://127.0.0.1:11783/metrics | promtool check metrics ``` A 401 means a missing/wrong Bearer header; query-string tokens are not accepted. A refused startup on a remote bind means the token was absent or invalid. If HTTP is healthy but the loop timestamp stops advancing, investigate the game loop rather than increasing the scrape frequency. If CPU/RAM timestamps stop too, check the sampler and process. Missing database/host/native metrics may simply mean that subsystem is disabled or unavailable on this platform. Related guides: [server setup](/docs/host-a-server), [startup configuration](/docs/server-startup), [Warden](/docs/warden), [RCON](/docs/rcon) and [server resources](/docs/server-resources). --- Source: https://open2077.net/docs/community-hub-warden # Manage Workshop resources with Warden Use Warden to install, update, remove or roll back resources from the OPEN//77 Workshop. Warden displays a plan before applying changes, including dependencies, permissions and affected files. For resource manifests and the client/server split, see [Server resources](server-resources.md). For assets prepared before the game starts, see [Mods](mods.md). The website side of the Workshop, for creators and readers, is in [the Workshop guide](https://open2077.net/docs/community-hub). ## Before you start Open Warden and select the **Workshop** tab (subtitle "Community resources"). Two settings in `server.jsonc` decide what the tab can do: - **Browsing** uses the master your server is registered with (`masterServer`). Every listed OPEN//77 server already has one, so search works out of the box. **Workshop not connected** means `masterServer` is missing or invalid. - **Installing** needs the Workshop file gateway. Add it once and restart: ```jsonc "warden": { "enabled": true, "hubFileGatewayOrigin": "https://files.open2077.net" } ``` Without it you can browse but every **Create installation plan** answers that installation is unavailable. Installation and maintenance also wait until startup recovery has finished after each restart. A Warden login controls this server only; it is separate from your website creator account. Being a creator on the website grants no rights here. | Permission | Allows | | --- | --- | | `hub.view` | Browse the Workshop and read release information. | | `hub.manage` | Create and apply install, update, uninstall, rollback and storage cleanup plans; see installed packages, history and restart staging. | | `hub.publish` | Connect a creator account and export resources from this server into a private creator draft. | | `restart.schedule` | Schedule the server restart that staged preload changes need. | Do not share Warden credentials, and never put creator tokens inside a resource archive. ## Install a creation 1. On the website, open the creation's page and press **Copy link** in the **Install with Warden** box. The link looks like `https://open2077.net/workshop/auto-taxi`; the short form `workshop:auto-taxi` also works. 2. In Warden's **Workshop** tab, paste the link into the search field and press **Search**. Warden looks the creation up on the OPEN//77 master, shows it as a single tile and opens its releases. If the address now points at a different creation, Warden says so; open the page on the website to get the current link. 3. Pick the release you want. Each release shows its version, state, the resources it contains, **Tested on** (the builds the creator declared) and its declared dependencies. 4. Press **Create installation plan**. If the release does not list your server build, Warden stops and offers **Acknowledge untested build and retry**; see "Plan options" below. 5. Read the plan (see "What the plan shows"). Fix any blocker. If you change local files or configuration while the plan is open, create a fresh one. 6. Tick the consent box and press **Install reviewed plan**. The **Installation job** panel follows the job; **Cancel installation** stays available until it finishes. 7. The install is done when the job reports **committed**. A finished download or private staging is not an install. Ordinary Lua changes activate in the running server. The package is downloaded, inspected and prepared before the runtime switches over. The job keeps running on the server if you leave the page; the panel reappears when you return to the Workshop tab in the same browser session. You can also search by term instead of pasting a link: type a word, pick a category chip (**All**, **Scripts**, **Gamemodes**, **Maps**, **UI**, **Tools**) and a **Sort** (**Trending**, **Newest**, **Recently updated**, **Most upvoted**, **Most downloaded**, **Featured**). Results are tiles with the cover image (or the hover clip's poster), category and maturity chips, creator, upvotes, downloads and update date. Press **Releases & install** on a tile to open its releases. A showcase tile says **Showcase · nothing to install**. The small `workshop:` label on every tile is the short link you can paste later. In the **Resources** tab, a package installed this way carries a **Workshop · v1.0.1** chip (with its own version), and the summary counts how many resources come from the Workshop. Manage them under **Installed from the Workshop**, not by editing their files. ## Update 1. Search the same creation and pick the newer release. 2. Press **Create installation plan**. Warden compares the release with what is installed, including files you changed locally, and lists every difference. 3. Review and install as for a first install. Automatic updates do not exist: nothing changes without your review. ## Roll back 1. In **Installed from the Workshop**, unfold **History, restart staging and storage**. 2. Under **Installation history**, press **Review rollback** next to the job you want to return to. The button only appears while that job's backup is still retained. 3. Warden builds a fresh reverse plan against the server as it is now. Review it, tick the consent box and press **Apply reviewed rollback** (or **Stage reviewed rollback for restart** when preload assets are involved). Rollback keeps current configuration and user files as its review describes; it is not a blind restore of every historical byte. It must still satisfy current release availability, compatibility and dependencies. It refuses to run if the old release is unavailable or revoked, the original code can no longer be recovered, or a current dependent needs a different version. The original history and backup stay unchanged. ## Uninstall 1. In **Installed from the Workshop**, tick the packages under **Pick**. 2. Press **Review uninstall**. Warden checks both package dependencies and runtime resource dependencies, including dependents you did not select. 3. Review the removed resources, preserved paths, load rules and any restart requirement, tick the consent box and press **Apply reviewed uninstall**. Your configuration and user files survive removal, which can leave an inert resource directory behind. A modified manifest is kept under an uninstalled filename so it can no longer load. Warden never takes over another creation's resource name and never silently removes a dependency that came from outside. An uninstall is recorded as its own job. ## Installed from the Workshop This table lists every package Warden installed: **Pick**, **Resources**, **Version**, **Workshop status** and **Installed** date. **Refresh** rereads it and rechecks each release with the Workshop. The status chip means: - **published**: the Workshop still lists this exact release; - **revoked**: the Workshop withdrew it. Installs and updates of this release are blocked; your installed copy stays and an alert asks you to decide whether to uninstall; - **not listed**: the release is no longer public, which does not confirm a revocation; - **status unavailable**: the Workshop could not be reached; press **Refresh** to retry. The folded section **History, restart staging and storage** holds **Installation history** (with **Review rollback**), **Restart staging** and **Private storage retention**. ## Export your resources to the Workshop With `hub.publish`, the Workshop tab also shows three creator sections. 1. **Creator account.** Enter a label and press **Connect creator account**. Warden shows a code and an **Open creator approval** button. Approve the requested scope on the website; only the Warden session that started the connection can finish it. **Check authorization** confirms a saved connection; **Disconnect creator** ends it. 2. **Export your resources.** Tick the resources to share, enter a **Release version** and press **Preview export**. Read the included and excluded files, findings and warnings, tick the consent box and press **Create reviewed ZIP**. When it is ready, **Download reviewed ZIP** lets you inspect it, and **Send ZIP to creator draft** sends it to your account as a private draft after you fill in the license, changelog and release details and tick the rights box. **Private draft accepted for editing** means the draft exists on the website and is not published; **Open creator draft** takes you there. 3. **Saved creator transfers.** Transfers survive a disconnect or a Warden restart. Press **Check or resume reviewed transfer** to continue the same upload instead of creating a second draft; **Discard local transfer** removes it. Warden never exports your server identity, signing keys, live configuration, logs or player data. The secret scan is a heuristic; read the file list. The full host-side description is in [the Warden export guide](https://github.com/Open2077/open77-base/blob/feat/community-hub/docs/community-hub-warden-export.md). ## Details: plan options Every release has a **Plan options** fold above **Create installation plan**: - **Allow prerelease versions**: needed to install a release with a prerelease suffix such as `1.1.0-beta.1`. - **Optional dependency project IDs**: optional dependencies you want installed as well. - **Manually satisfied external resource names**: resources the package requires that you already provide yourself. - **Release IDs acknowledged as untested on this build**: releases you accept although the creator did not list your server build. **Acknowledge untested build and retry** fills this in for you. It is your decision, not a compatibility guarantee; manifest and dependency rules still apply. The fold also lists the selected release ID and its required and optional dependencies with their version constraints. ## Details: what the plan shows A plan is an immutable review that expires after a short time. It shows: - the **plan hash**. When you accept, you accept that exact hash; Warden cannot swap in another release or other options behind it; - **blockers**, which must be resolved before the install button appears; - whether a **server restart** or a **game restart** is required; - **Stop order** and **Start order** of resources; - **Proposed load rules**, the `resources.load` rules the plan adds; - **Preload assets** and **Operational notes**; - for each package, **Requested permissions** and, per resource, the file entries: the action for every path, with its reason, size and SHA-256. Owned code, untracked files and files you modified each get their own action. Read them rather than assuming every local edit is safe to replace. **Discard plan** throws the review away. Maintenance plans (uninstall, rollback) show the same fields plus removed runtime resources, restored packages and preserved directories. ## Details: job states The job panel polls the server and ends in one of four states: - `committed`: the change is live and the installed inventory revision advanced; - `cancelled`: you cancelled it and recovery has finished; - `failed`: the message names the cause; check the server's recovery state before retrying; - `awaiting_restart`: files are staged privately for a restart (see below). While `cancel_requested` shows, Warden is waiting for the running step or its recovery to finish. A job that was interrupted is recovered from the private local journal at the next startup. Do not delete private Workshop state or signing identities to force recovery past an error; keep the artifacts and resolve the reported conflict. ## Details: configuration and local files Package authors can declare configuration templates in `open77-hub.json`. Warden writes them to their destinations and preserves your existing local values as the plan describes. Keep your own backups of important server data. ## Details: preload changes and restarts A resource can declare `preload_mod` assets that must be prepared before Cyberpunk starts. Any install, update, rollback or uninstall that touches such assets is staged instead of applied: the button reads **Stage reviewed plan for restart** and the job ends in `awaiting_restart`. Live files, configuration and inventory stay unchanged while the reviewed files are staged privately. Under **History, restart staging and storage › Restart staging**, the pending job is listed with its remaining preload assets. **Cancel staged restart** drops it. **Schedule server restart** (needs `restart.schedule`) stops the server after 30 seconds; your service manager must relaunch it. Players must relaunch through the launcher when the required asset set changes. Staging survives logout and page reload, and while a job is pending no other Workshop change can start. At startup, before preload resolution, Warden re-checks master authority, release metadata, dependencies, the reviewed options, server and game versions, inventory, configuration, manifests and the captured resource files. If the authority is offline, the content was revoked or something was edited in between, the pending job is cancelled and the previous installation boots. Fix the cause and review a new plan; changed conditions never widen the original consent. After the staged paths are applied, preload resolution and the required Lua startup must both succeed before the inventory commits. A startup failure restores the saved state. Server-side tests cover this flow, but they do not prove that a given archive loads in the game. Validate your own assets through the launcher and the game before making them a requirement for players. ## Details: private storage retention Retention acts on the private records of completed Workshop jobs, never on live resource directories. Under **History, restart staging and storage › Private storage retention**, press **Review storage cleanup** to see what stays protected, which duplicate payloads can be removed and which jobs can be compacted. Tick the consent box and press **Apply reviewed cleanup**. Compacting a job permanently expires its rollback files; its history receipt remains, with rollback no longer available. The default policy keeps at least 30 days of completed jobs, the newest three committed installs per creation, the providers of currently installed releases, and the source jobs that retained maintenance history needs. It also protects active and recovery jobs, malformed or linked data, changed or unknown files, unique configuration, user or modified-file backups, and differing root configuration snapshots. These safeguards can prevent reclaiming all the space you asked for. Inspection is bounded at 1,000 live job records, 10,000 files and 500 MiB per job snapshot, and 10,000 compact history receipts. Oversized snapshots are protected. Review retention before reaching the job limit; the limit never permits deleting unresolved recovery data. Interrupted compaction resumes from its receipt. Retention is not a substitute for your backup policy. --- Source: https://open2077.net/docs/warden-players # The Players tab in Warden Manage connected players in Warden's **Players** tab. The live roster refreshes automatically and provides warnings, kicks, bans, in-game permissions, healing, freezing and movement controls. This page covers the roster columns and the per-row actions. The whitelist and ban door list has its own tab and its own page — see [Connection control](connection-control.md). Managing operator accounts and roles is in [Identity and ACL](server-acl.md). ## Permissions Every action is gated separately, so a role can hold one without the others. A role holding `players.view` sees the tab; each button appears only when the session's role holds its permission, and the server enforces it again on every call. | Permission | Allows | | --- | --- | | `players.view` | See the tab and the roster. | | `players.warn` | Send an admin message to one player. | | `players.kick` | Disconnect a player (they can rejoin). | | `players.ban` | Ban a player's identity and disconnect them. | | `players.heal` | Restore a player to full health, or revive them where they fell. | | `players.freeze` | Hold a player's body still, and release it again. | | `players.teleport` | Move a player to coordinates or to another player, and bring a player to another. | | `acl.edit` | Grant or revoke in-game rights from a row (the *Rights* button). | | `access.edit` | Add a player to the whitelist from a row (the *Allow* button). | `players.heal`, `players.freeze` and `players.teleport` sit beside the moderation four rather than inside them on purpose. Removing tonight's troublemaker and pinning a player in place during a match are different authorities: a server can hand a greeter role the whitelist *Allow* button and a game designer the freeze and move buttons without either becoming a full moderator. The owner role (`*`) holds them all. ## The roster columns | Column | What it shows | | --- | --- | | **ID** | The player's numeric session id. Every action is keyed on it. | | **Name** | The display name, with how long ago they connected. | | **Identity** | The account GUID, shortened; click to copy the full value. | | **Ping** | Round-trip time in milliseconds, coloured by health. A dash means the transport has no sample yet — never a misleading `0 ms`. | | **Health** | Current health as a percentage of their maximum, with armour beside it. A dash means the server holds no health record for this player. | | **State** | `alive` is the quiet default and shows nothing; `dead`, `loading`, `frozen`, `god`, `ghost`, `hidden`, `in vehicle`, `watching #N` and `not ready` each show a chip. | | **Match** | The routing bucket: `lobby` (bucket 0, the shared world) or `#N` (an isolated match — a Pursuit round, say). Two players in different buckets cannot see each other. | | **Rights** | Their in-game role or grant count, from the ACL, and a *listed* chip when they are on an enabled whitelist. | | **Position** | Their last known X, Y, Z. Past two seconds it is shown greyed with its age, because "one late tick" and "never reported" are different facts an operator must be able to tell apart. | The roster deliberately carries **no IP address**. The server knows one, and the `Open77.players.endpoint` read puts it behind the `players.identity.sensitive` manifest capability precisely because an endpoint is the one field that can follow a player home. A browser panel on a box an operator may share is not a stronger place to keep it than a resource manifest, so it is not published here at all. ## Heal The **Heal** button restores a living player to full health and tells them an administrator did it. On a **dead** player the button reads **Revive** instead and brings them back where they fell at full health with a short grace window — a dead body cannot be topped up, so the one action does the right one of the two and the toast says which. Health writes go through the same stats authority `Open77.players.setHealth` uses, so god mode and the life-phase interlocks still apply. Every panel heal — and only a panel heal — raises the host-wide event `open77:admin:playerHealed(playerId, author)`. A resource calling `Open77.players.heal` does **not** raise it: `open77:admin:` means "the operator's own surface did this", and announcing every gameplay heal under that name would be a firehose and a lie. See [Admin events](server-api.md#admin-events). ## Freeze **Freeze** holds a player's body still; the button then reads **Thaw**. A frozen player keeps their camera, chat, voice, menus and interaction prompts, and still takes damage — it is a primitive, not a policy. See [Player freeze](player-freeze.md) for exactly which inputs it blocks. The hold is **cooperative and keyed to the panel**. A gamemode script may hold the same player at the same time, and *Thaw* drops only the panel's claim: if a script still holds them, they stay frozen and the toast says so, rather than letting an operator think they released somebody they did not. The panel's claim is released automatically on the player's death, respawn, bucket change and disconnect, the same as any other holder — a player can never be stranded frozen. ## Move **Move** opens a dialog with three modes: - **To coordinates** — prefilled with the player's own position, so a small nudge is a small edit. Their routing bucket is left alone. - **To another player** — the player is placed beside that player (offset to the side so two bodies never resolve inside each other) and into that player's routing bucket. - **Bring another player here** — the inverse: pick somebody else, and they are moved beside this player, into this player's bucket. The dialog warns before you click when the player to be moved is dead, still behind the readiness gate, or in a vehicle (a move is refused unless you tick *Take them out of a vehicle first*). A move is the one admin action that is **not** instant. The server does not move the body; it asks the owning client to, and the client fades the screen, teleports, then watches the body settle on solid ground for three frames before answering — because a direct write over any real distance can drop a player through world that has not streamed in, whereupon the engine's fall-under-world failsafe silently returns them to the save's spawn point kilometres away. So the panel waits for the client's answer and reports what the **body** did: `arrived`, or a named refusal (`player_in_vehicle`, `player_not_alive`, `settle_timeout`). A move whose client never answers in time is reported as *pending*, not as a success — it may still land, and the panel does not guess. Bucket changes happen before the body moves, so an observer left behind never glimpses the destination; if the move is refused, the bucket is rolled back so the player is never left alone in a world nobody meant them to see. ## Spectate is not available txAdmin's player panel has a spectate button. Warden's does not, and it is not an oversight. The platform *has* a spectate primitive (`Open77.players.spectate`), but it needs a spectator who is a connected player: it ghosts that player, hides their body and puts *their client's camera* on the target. A Warden session is a browser with no body to ghost and no renderer to receive frames, and Cyberpunk 2.31 exposes no server-side view of the world to stream to one. The Players tab therefore *reports* who a player is watching (the `watching #N` chip) but offers no way to start one. An operator who wants to watch a player joins the server and uses `Open77.players.spectate` from an in-game admin resource, with a body. ## Everything is audited Every action writes one line to the audit trail (the **Audit** tab, permission `audit.view`): the operator's panel username, the action (`players.heal`, `players.freeze`, `players.unfreeze`, `players.teleport`, `players.bring`), the target and the outcome. A refused action is recorded as failed with its reason, so the trail is a true record of what an operator did and what actually happened — not only what they tried. --- Source: https://open2077.net/docs/server-resources # Lua resources loaded by the server Package server and client Lua scripts, permissions, dependencies and web assets in an Open77 resource. The server loads the declared resources and distributes their client files to joining players. Use this guide to write a resource, understand what reaches the player, and publish a change to a running session. ## Layout of the resources folder The library is grouped in category folders. A folder without an `open77.lua` is a category, not a resource; the server's load rules, the wizard and the tooling look through categories, so a `resources.load` written as bare names (`"open77_shell"`, `"freeroam"`) or as `"*"` keeps working. ```text resources/ system/ official platform resources: open77_shell, open77_chat, open77_props, open-voice, ... gamemodes/ playable modes and their HUDs: freeroam, race, pursuit, open77_deathmatch, open77_cordon, ... dev/ examples and harnesses: open77_example, open77_dbtest assets/ loaders/ archivexl, tweakxl (tracked: the platform redistributes them) maps/ world packages such as kujira_world (ignored by git) vehicles/ vehicle packages (ignored by git) mods/ other third-party packages (ignored by git) ``` Rules: the last segment of a load rule (`*`, `open77_*`, a bare name) matches through up to two levels of category folders; earlier segments stay literal, so `gamemodes/race` is exact and `assets/**` is the explicit recursive form. Third-party content under `assets/maps`, `assets/vehicles` and `assets/mods` is never committed; only its manifests are yours to write. `scripts/new-resource.ps1` takes `-Category` (default `gamemodes`), and `scripts/check-lua.ps1` resolves a bare name in any category. ## Connection flow 1. The GNS handshake announces a generation, a SHA-256 digest, an Ed25519 key, and an HTTPS URL. 2. The shell shows a full-screen loading WebUI. 3. The client verifies the signed set, the CBOR manifests, each chunk, then each file. 4. The complete generation is committed under `red4ext/plugins/Open77/cache/server-resources/sets//resources`. 5. Only then does Open77 load the pristine save. 6. The local Lua runtime is replaced by the server's client image. The screen reports `manifest`, `downloading`, `verifying`, `ready`, or `failed`, along with the resource, the byte count, and the number of files. A signature, hash, or path error blocks entry and disconnects the client. ## Writing a resource The resource lives in a directory selected by the server's `resources.load` rules. Bare names and relative paths use `resources.root` as their base: ```lua resource "garage" version "1.0.0" auto_start true shared_script "shared/config.lua" server_script "server/main.lua" client_script "client/main.lua" web_ui_page "web/index.html" web_files { "web/**" } files { "assets/blips/*.png", "assets/audio/*.wav" } preload_mod "dist/world-assets.zip" permissions { "network.events" } ``` The server runs `server_script` and `shared_script`. The downloaded package contains `open77.lua`, the `client` and `shared` files, and the declared assets — never the `server/` files. The client and server Lua APIs are documented in the wiki's main reference. The authoritative subsystem for ground items is covered in [loot.md](loot.md). The reference time/weather package and its protocol are covered in [weather.md](weather.md). `files` / `file` declare generic client assets. Globs are expanded by the server, included in the signed resource set, downloaded before Lua starts, and recorded in the client allowlist. Use `web_files` only for files served to that resource's WebUI. For example, `Open77.assets.texture("assets/blips/job-center.png")` only succeeds when the exact file matched a `files` entry. Empty globs, traversal paths, oversized files, and undeclared texture reads fail the resource instead of falling back to arbitrary disk access. ### Declarative exports and ported manifests A resource may list its exports in the manifest instead of calling `exports(name, fn)`: ```lua exports { "GetClosestDoor", "IsDoorOpen" } -- client exports server_exports { "GetBalance", "AddMoney" } -- server exports ``` Each side pre-registers its own list from the **global function of that name** after the last script has run and before the resource is `Running`, through exactly the registration a scripted `exports()` call performs -- so a sibling calling `exports.bank:GetBalance()` from its own start handler finds it, and a function defined in any file counts. The manifest is the later word: a name registered by `exports()` and listed here ends up bound to the global. A listed name with no global function refuses the start by name (`manifest_export_missing:`), never starts a resource whose manifest promises what its code does not provide; a name that is not a Lua identifier of at most 64 characters fails the manifest at discovery (`invalid_manifest_export::`). `Open77.resource.metadata(name, "exports")` and `"serverExports"` read the lists back. `ui_page` is accepted as an alias of `web_ui_page`, with the same rules (a safe relative path that must also be declared in `web_files`). When a manifest carries **both**, the one written last in the file wins -- what a Lua-executed manifest would do -- so a ported manifest that kept its `ui_page` line above a new `web_ui_page` line gets the Open77 one, and vice versa. The FiveM keys that describe the FiveM runtime or metadata nothing here reads are **accepted and ignored**: `fx_version`, `game`, `games`, `lua54`, `use_experimental_fxv2_oal`, `author`, `description`, `provide`, `provides`, `escrow`, `escrow_ignore`. A ported manifest loads without editing them out, and so that the acceptance never passes for something done, each side names the ones it saw once per start, in one `INF` line: ```text INF|my_resource|manifest_ignored_keys=fx_version,lua54 ``` `@other_resource/file.lua` in a script list -- FiveM's cross-resource include -- is refused **by name** on both sides, `cross_resource_include_refused:@other_resource/file.lua`, at manifest parsing. It is a design decision, not a limitation: a resource's scripts run in its own VM, and sharing code across resources is `dependency` plus [`require('@resource/module')`](lua-modules.md) over declared `files` on the client, and [exports](resource-exports.md) on both sides. Before this the include matched nothing and either failed as "Resource contains no scripts." or, when the manifest listed other scripts too, was dropped without a word. ### Pre-boot REDengine assets `preload_mod` / `preload_mods` attach inert game assets to a selected resource when REDengine must see those files before Lua starts. Typical examples are `.archive` packages containing XBM texture replacers or custom world data: ```lua resource "open77_sky_ads" version "1.0.0" server_script "server/main.lua" preload_mod "dist/Open77SkyAds.zip" ``` The ZIP or 7z is inspected at dedicated-server startup, staged in the server's content-addressed mod store, and promoted to the required-mod set announced to the master. The launcher downloads and projects it before it starts Cyberpunk. A preload is deliberately excluded from the signed Lua resource package, including when a broad `files { "dist/**" }` pattern also matches it. This is a boot contract, not a hot-reload API: - the resource must be selected by `resources.load`; - `requiredMods.enabled` must be `true`, or server startup fails with the responsible directive; - only inert packages are accepted automatically; executable DLL/REDscript/CET content still goes through Warden review; - a resource may declare at most eight `.zip`/`.7z` packages, each from 1 byte through 64 MiB; - every declared path or glob must match an existing package; - rebuilding or changing one requires a dedicated-server restart, then a fresh launcher boot of the game; restarting only the Lua resource cannot remount the REDengine depot. See [Sky hologram advertisements](sky-advertising.md) for the complete PNG/DDS-to-XBM example and [Server-required mods](server-mods.md) for storage, consent, trust, and removal semantics. A server script can register a command reachable from the Open77 developer terminal or from the dedicated console: ```lua RegisterCommand("garage.list", function(source, args, rawCommand) -- source = authenticated playerId from the Open77 terminal, 0 from the dedicated console. print("garages: " .. tostring(#args)) end, false) -- true puts the command behind the `command.garage.list` ACL ``` In the in-game terminal, Open77 runs local native commands first. If no local handler matches, the tokenised line is sent reliably to the server under the session's identity. The server never trusts a `source` supplied by the client. Commands are removed automatically along with their VM when a resource stops or reloads. Commands declared with `restricted=true` require the ACL permission `command.`. The `*` permission and namespace wildcards such as `command.garage.*` are accepted. See [server-acl.md](server-acl.md). A resource may also run a command, list what commands exist, and control the resource lifecycle, behind two capabilities that are deliberately not the same one: ```lua permissions { "runtime.commands" } -- invoke a SIBLING's ordinary command, and nothing else permissions { "resources.control" } -- start/stop/restart, and the operator's authority for a line ``` Everything on that surface is queued for the next tick boundary and reports acceptance rather than completion, because Lua runs inside the server's tick and a resource stopping itself synchronously would free the Lua state doing the stopping. See [Runtime and resource control](server-api.md#runtime-and-resource-control). ## Selecting which resources load The dedicated server does not have to load every directory below `resources.root`. The `resources.load` array is an ordered set of directory rules: ```jsonc "resources": { "enabled": true, "root": "../resources", "load": [ "open77_shell", "open77_chat", "open77_vehicles", "freeroam", "open77_freeroam" ], "autoStart": true } ``` An exact list is recommended for production and gamemode profiles. Adding a new development resource to the library then cannot silently add it to a live server. The list is read at startup, and the first-run wizard writes an exact list (the template's resources, by name). To add a resource to such a server: create its directory, add its name to `resources.load` in `server.jsonc`, then either restart the server or -- on a running server -- run `refresh` (rescans the manifests on disk; only names the load rules admit are discovered) followed by `ensure `. `refresh` does not re-read `server.jsonc`: a name that was not in the list when the server started needs the restart. `ensure` on an unknown name answers `Resource '' was not found`, which is this case, not a broken manifest. ### Rule syntax | Rule | Result | |---|---| | `"freeroam"` | Selects `/freeroam`. | | `"open77_*"` | Selects matching immediate child directories. | | `"packs/*"` | Selects matching resources one level below `/packs`. | | `"packs/**"` | Recursively selects resources anywhere below `/packs`. `**` must be a complete path segment. | | `"garage/open77.lua"` | Selects the resource containing that exact manifest; the final `open77.lua` is optional. | | `"D:/Open77/shared/**"` | Selects from an absolute path outside `resources.root`. Forward slashes avoid JSON escaping on Windows. | | `"!open77_debug"` | Removes matches selected by an earlier rule. | Relative names and paths are resolved from `resources.root`. The root itself is resolved from the directory containing `server.jsonc`, not from the shell's current directory. `*` and `?` never cross a directory separator. A complete `**` segment crosses any number of directories. Rules run from left to right, so later rules can remove or re-add a resource: ```jsonc "load": [ "open77_*", // add every immediate open77_ resource "!open77_debug", // remove debug "!open77_example", // remove the example package "open77_debug" // re-add debug deliberately ] ``` Useful special cases: - Omitting `load` is equivalent to `"load": ["*"]` and preserves the old load-everything behavior. - `"load": []` selects no resources. - A rule matching no current directory selects nothing; the watcher will pick it up if a matching resource appears later. - Two selected directories declaring the same resource name are both rejected as ambiguous. ### Selection, startup, dependencies, and client delivery Selection and startup are separate gates. `load` decides which resources the host knows about. `resources.autoStart` decides whether the host starts selected manifests whose own `auto_start` is true. A selected manifest with `auto_start false` remains available to `ensure `; an unselected resource is unknown to `ensure`. Manifest dependencies are not silently added. Every dependency must also match `load`, otherwise starting the dependent resource fails and names the missing dependency. This keeps the configured set authoritative: an excluded gamemode cannot return indirectly through another manifest. Only selected resources that reach `Running` are packaged and signed for clients. Removing a resource from `load` therefore stops it server-side and removes its client scripts, WebUI, and declared files from the next resource generation. A resource is delivered to clients whenever it has client content: `shared_script`, `client_script` or `files`. A `server_script` never reaches a client and does not require a `client_script` beside it. A library that declares `files` and no client script is delivered as a **passive library**: the client gives it an idle VM, marks it `Running`, and dependants `require('@name/...')` its files (see [Lua modules](lua-modules.md)). What a manifest cannot be is empty: with no script and no `files` it is refused on both sides, `no_client_scripts` on the client and "Resource contains no scripts or files." on the server. `web_files` alone do not count. Builds up to 2.31.13+op77.82 refused every delivered resource without a client or shared script, and one such refusal failed the whole activation (`resource_activation_failed`, client stuck on "Verifying resources"); on those builds an empty `client_script` is the workaround. Selected `preload_mod` declarations are resolved earlier, during dedicated-server boot, because the launcher needs their digest before it can start the game. They are not silently pulled in from an excluded resource and they are not added by a dependency that `load` omitted. Filesystem matches are recalculated at every `watchIntervalMilliseconds` scan. Editing a resource, creating a matching directory, or deleting one is noticed automatically. Editing `server.jsonc` itself is not hot-reloaded: restart the dedicated server after changing `root` or `load`. Run `resources` at the server console or in Warden to verify the final discovered set. ## Publishing and reloading With `autoStart=true`, the server notices any valid change. It prepares a new server VM, publishes a new signed set, then sends `ResourceSetChanged` to the players. The client downloads only the missing chunks and switches generation once verification completes. An invalid Lua candidate keeps the previous generation. Useful server commands: ```text resources refresh ensure start stop restart reload ``` Client diagnostic commands: ```text resource.root resource.distribution resource.list resource.status ``` `resource.root` shows `source=bootstrap` before the session and `source=server` once active. `resource.distribution` exposes phase, generation, progress, digest, and current resource. Every one of the server commands above is visible to the running resources as events. `start`, `ensure` and `restart` announce the resource **before** it starts with `onResourceStarting(name)` — delivered inline, while `GetResourceState(name)` still reads `starting`; it is a notification, not a veto — then `onResourceStart(name)` once it runs; `stop` and `restart` announce `onResourceStop(name, reason)`; `reload` is a stop and a start to every other resource; and `refresh` ends with `onResourceListRefresh()` once the tree has been re-read and the new package set published. The Open77 spellings (`open77:resource:starting`, `started`, `stopped`, `refreshed`) carry a lifecycle revision beside each. See [Resource lifecycle events](server-api.md#resource-lifecycle-events). ## Security and operations - HTTPS is mandatory for a public URL; HTTP is limited to loopback. - HTTP redirects and absolute or traversal paths are refused. - The server must keep its signing key file across a migration. - The private key and the `.open77` cache must never be committed. - A server resource is code chosen by the operator: only grant sensitive permissions to resources you audit. Minimum configuration: ```json "resources": { "enabled": true, "root": "../resources", "load": ["open77_*", "garage", "!open77_debug", "../shared/**"], "autoStart": true, "download": { "enabled": true, "listenUrl": "http://0.0.0.0:11779", "publicBaseUrl": "https://cdn.example.net/", "cacheDirectory": ".open77/resource-cache", "signingKeyFile": ".open77/resource-signing-key.json", "chunkSizeBytes": 1048576 } } ``` See [Selecting which resources load](#selecting-which-resources-load) for exact names, wildcard and exclusion semantics, absolute path examples, dependencies, and restart behavior. --- Source: https://open2077.net/docs/resource-runtime # The Lua resource runtime Reference for resource manifests, scheduling, events, modules, exports, core namespaces, WebUI and sandbox limits. Start with [Server resources](server-resources.md) for resource structure and distribution. OPEN//77 embeds **PUC Lua 5.4.8**. Each resource receives its own Lua state, scheduler, memory allocator, permission set and lifecycle. Developers coming from FiveM will recognise the client/server split, events, exports, commands and a manifest-driven lifecycle; the APIs themselves are independent and reflect REDengine constraints. ## Layout A resource is a directory below the server's configured `resources.root`, containing an `open77.lua` manifest: ```text resources/garage/ open77.lua shared/config.lua client/main.lua server/main.lua web/index.html web/app.js assets/garage.png ``` The server runs `server_script` and `shared_script` files. It distributes the manifest, the client and shared scripts and the declared client assets as a signed resource set. **`server/` files are never included in the client package.** Secrets and authoritative decisions belong exclusively in server code. The client does not discover arbitrary gameplay resources from disk. Its local bootstrap directory is reserved for the trusted connection and loading interfaces; session resources come from the server. ## Manifest reference The manifest is parsed as a declarative DSL. **It is not executed as Lua** — which is why `auto_start true` is legal there and would be a syntax error in a `.lua` script. | Directive | Purpose | |---|---| | `resource` | Stable resource name. Defaults to the directory name. | | `version` | Resource semantic version. Defaults to `0.0.0`. | | `open77_version` | Compatible OPEN//77 version range. | | `auto_start` | Start automatically when the set becomes active. | | `reload_policy` | Client transition policy: `local` (default), `best_effort`, `synchronized` or `reconnect`. | | `shared_script` / `shared_scripts` | Lua executed on both hosts. | | `client_script` / `client_scripts` | Lua distributed to and executed by clients. | | `server_script` / `server_scripts` | Lua executed only by the dedicated server. | | `dependency` / `dependencies` | Required resources, with optional version constraints. | | `permission` / `permissions` | Capabilities requested by the resource. | | `file` / `files` | Generic files distributed to the client; available to WebUI through the [shared asset protocol](webui-assets.md). | | `ui_page` / `web_ui_page` | Default WebUI entry: a declared local file or an HTTP(S) URL. See [remote pages](#remote-pages-external-content-and-hot-reload) for supported entry types. | | `web_ui_auto_create` | Whether to create that page at resource start. | | `web_file` / `web_files` | Files served to this resource's WebUI origin and through `open77://RESOURCE/PATH` on clients with shared asset support. | | `pause_menu_logo` | Optional server logo beside OPEN//77 in the Escape menu: an HTTP(S) image URL or a declared local asset. Requires `pause_menu.customize`; see [Pause menu customization](pause-menu.md). | Singular and plural forms are accepted everywhere. Globs support `*`, `?` and `**`, and expand in a deterministic order: patterns in the order they appear in the manifest, and ordinal order within one wildcard pattern. ```lua resource "garage" version "1.0.0" open77_version ">=0.0.1" auto_start true reload_policy "local" shared_script "shared/config.lua" client_script "client/main.lua" client_script "client/hud.lua" server_script "server/main.lua" files { "assets/**/*.png" } web_ui_page "web/index.html" web_ui_auto_create false web_files { "web/**" } dependencies { "inventory >=1.0.0", "open77_notifications" } permissions { "local.events", "network.events" } ``` Absolute paths, Windows drive paths, traversal through `..`, files outside the resource and non-Lua script entry points are all rejected. Empty glob results and incompatible dependency graphs prevent the candidate resource from starting. ### Never glob script entries `**` requires at least one intermediate directory, so `client/**/*.lua` matches **nothing** against a flat `client/main.lua`. An empty glob result prevents the resource from starting — and on the client that means the whole session's resource set is refused with `script_pattern_empty:client/**/*.lua`, so **no player can connect**. Every shipped resource lists its scripts explicitly, one per line. `**` is safe for `files` and `web_files`, where the directory nesting genuinely exists. ### Load order Scripts load in manifest order, which is what lets one file publish a table that a later file in the same resource consumes. ## Lifecycle and scheduling ```lua AddEventHandler("onClientResourceStart", function(name) if name == GetCurrentResourceName() then print("Resource started") end end) AddEventHandler("onClientResourceStop", function(name, reason) if name == GetCurrentResourceName() then print("Resource stopped: " .. tostring(reason)) end end) CreateThread(function() while true do Wait(1000) print("tick") end end) local timer = SetTimeout(5000, function() print("timeout") end) ClearTimeout(timer) ``` `Wait(0)` resumes on a later scheduler pass. Each callback runs in a resource-owned coroutine. Tasks and handlers are cancelled when their resource stops or reloads. The client also emits `open77:worldReady` when the game-facing runtime becomes available. **It fires more than once per session, and not at all after a hot reload** — see [Drawing in the world](world-drawing.md#rebuild-on-start-not-only-on-worldready) for the pattern that handles both. ## Local and network events ```lua local handler = AddEventHandler("garage:opened", function(id, data) print(id, data.label) end) TriggerEvent("garage:opened", 42, { label = "Hella" }) RemoveEventHandler(handler) ``` Namespaced equivalents exist and are identical: ```lua local handler = Open77.events.on("garage:opened", callback) Open77.events.emit("garage:opened", 42, { label = "Hella" }) Open77.events.off(handler) -- the handler id alone, not the event name ``` Network events require the `network.events` permission: ```lua RegisterNetEvent("inventory:state", function(state) -- Only handlers explicitly registered as network events receive packets. end) local ok, reason = TriggerServerEvent("inventory:request", { page = 1 }) -- Equivalent: Open77.net.on(...) / Open77.net.emitServer(...) ``` The event codec accepts `nil`, booleans, finite numbers, strings, and acyclic tables with string or integer keys. Functions, threads, arbitrary userdata, cycles, excessive depth and oversized values are rejected. Network arguments are encoded as bounded JSON and delivered reliably and in order. On the server, `source` is assigned from the authenticated connection. **A client cannot select or forge another player's source ID.** ## Modules and exports ```lua local helpers, reason = require("shared.helpers") ``` On the **client**, this local import resolves `/shared/helpers.lua`, then `/shared/helpers/init.lua`. Publish downloaded modules under `files`; they are text-only and cached per calling Lua VM. The dedicated-server sandbox does not expose `require`. The newer client implementation introduced with PolyZone also supports `require('@polyzone')` and `require('@library/helpers.math')` for explicitly published files of declared, running dependencies. The imported code runs in the **caller's** VM and permissions, not as a call to the provider. This dependency syntax is available starting with client `2.31.13+op77.67`. See [Lua modules and require](lua-modules.md) for complete manifests, local and dependency examples, cache/reload behavior, errors and how to choose between modules and exports. Exports allow asynchronous calls between isolated resources on **both client and server**. The registries are separate: a server call reaches a server resource, never a player's client resource. ```lua -- The publishing resource exports("openMenu", function(id) Wait(10) return { opened = true, id = id } end) ``` ```lua -- The calling resource CreateThread(function() local promise, reason = Open77.exports.call("garage", "openMenu", 42) if not promise then print(reason) -- e.g. "export_not_found" return end local result, callError = promise:await() print(result and result.id, callError) end) ``` There is **no** FiveM-style `exports.:()` proxy. `exports` is a plain function used to *publish* an export; indexing it raises *attempt to index a function value*. The client sandbox also removes `setmetatable` and `getmetatable`. Always call through `Open77.exports.call`. Arguments and results are copied through the bounded value codec. Lua objects are never shared between states. Stopping either resource invalidates outstanding generation-owned requests. Inside an exported function, `GetInvokingResource()` and `GetInvokingResourceGeneration()` identify the real caller for that coroutine resume; they return `nil` outside an export invocation. Services use these for ownership. **Accepting an owner name as a normal Lua argument would let any caller impersonate another resource** — see [Writing a gamemode](writing-a-gamemode.md#services-that-others-call) for the guard every service should copy. ### Server-to-server exports The **server** runtime now installs `exports`, `Open77.exports.call`, `GetInvokingResource`, `GetInvokingResourceGeneration` and `GetCurrentResourceGeneration`. Use the same publish/call/`:await()` form above from `server_script` files. A gamemode can split reusable services into separate resources while retaining isolated VMs. The provider executes with **its own permissions**, not those of the caller. Authorize callers with `GetInvokingResource()` and validate every argument. Player `source` is not inherited by an exported callback. Register at file scope, but make calls only from a running resource: candidate preparation refuses them with `resource_preparing`. Await pending calls from a managed thread or handler. Requests are generation-bound; stopping/reloading a provider rejects its pending calls. Server requests have a 30-second timeout and bounded copied arguments and results. `TriggerEvent` remains per-VM: exports do not add a global event bus. See [Cross-resource server exports](server-exports.md) for a complete service and consumer example, limits, errors and reload behavior, and [The gamemode kernel](gamemode-kernel.md) for modular gamemode design. This needs the updated server binary; it is not enabled on an older server by changing Lua alone. No client update is required. ## Core runtime API ```lua Open77.log.debug("message") Open77.log.info("message") Open77.log.warn("message") Open77.log.error("message") local name = Open77.resource.name() local version = Open77.resource.version() local generation = Open77.resource.generation() local otherGen = Open77.resource.generation("other_resource") local state = Open77.resource.state("other_resource") local allowed = Open77.resource.hasPermission("network.events") local text, why = Open77.resource.readFile("config/settings.json") local seconds = Open77.time.monotonic() local runtimeVersion = Open77.runtime.version() local luaVersion = Open77.runtime.luaVersion() local json = Open77.json.encode({ enabled = true }) local value = Open77.json.decode(json) ``` `GetCurrentResourceName()` and `GetResourceState(name)` are provided as familiar aliases. `Open77.resource.readFile` is the only general file-reading primitive, and it is confined to the current resource. It does not grant access to the repository, the game directory, another resource, or an absolute path. ### Declared assets Generic client assets must be declared with `files` in the manifest. A script can inspect only its own allowlisted files: ```lua files { "assets/**/*.png" } ``` ```lua local texture, reason = Open77.assets.texture("assets/garage.png") local assets = Open77.assets.list() ``` Undeclared texture reads, empty globs, traversal paths and oversized files fail the resource rather than falling back to arbitrary disk access. ### Notifications from a server resource A server resource can target the shared [`open77_notifications`](https://github.com/Open2077/open77-resources/tree/main/system/open77_notifications) WebUI without defining a network protocol of its own: ```lua local id, reason = Open77.notifications.send(playerId, { type = "success", title = "Saved", message = "Your character has been saved.", durationMs = 4500, }) assert(id, reason) Open77.notifications.update(id, { message = "Save synchronized." }) Open77.notifications.dismiss(id) Open77.notifications.broadcast({ message = "Server restart in five minutes." }) Open77.notifications.clear(playerId) -- omit playerId to clear this resource everywhere ``` IDs and target records are isolated per server resource and cleaned up on expiry or resource stop. Add `dependency "open77_notifications >=1.0.0"` so the client renderer is part of the session resource set. Full definition fields are in [Notifications](notifications.md). ### Carrying state across a reload A hot reload gives a server resource a brand-new Lua VM with an empty roster while the server is still full. `Open77.state` is a small bag the **host** keeps on the resource's behalf, so a few authoritative fields can survive that. ```lua Open77.state.save({ round = 4, scores = scores }) -- true, or false, reason local carried = Open77.state.load() -- the table, or nil Open77.state.clear() ``` The value is JSON-encoded, so it must be a plain acyclic table — the same codec every other boundary uses. It is capped at **64 KiB**: this is a handful of authoritative fields that must survive a reload, not a database. A value that cannot be serialised answers `false, "unserialisable_state"`. **A reload carries the bag; a stop drops it.** That asymmetry is deliberate — it leaves an operator a "come up as at boot" lever that a reload does not take away. It is the opposite of the rule for [operator tunables](tunables.md), whose values survive both, because stopping a resource for an evening must not quietly undo an afternoon of tuning. This is per-resource and server-side only. ### The database bridge is per-server, not per-resource For installation, credentials, connection settings and a working Lua probe, see [Configure a SQL database](/docs/database). There is **one** database connection for the whole server, built once from a single `database.connectionString`. The `database.access` permission is a boolean gate on reaching it — not an allocation of a private database. The consequences are worth stating plainly, because nothing in the API hints at them: - Every resource holding `database.access` talks to the **same** database, with the same credential. - There is no per-resource schema, table prefix or statement filter. A resource can read and write another resource's tables. - Turning the database on turns it on for **every** resource that asked for the permission, not just the one you had in mind. That is not theoretical: it is exactly how enabling persistence for a ranked ladder also switched on a character creator on join and took a live server down. See [the readiness gate](readiness-gate.md#why-this-exists). **Prefix your tables** with your resource name, grant `database.access` only to resources that genuinely persist state, and treat the shared database as shared. #### `?` is only a placeholder outside strings and comments Positional parameters are rewritten before the statement is sent, and the scanner understands `'…'`, `"…"` and `` `…` `` quoted spans, `--` and `#` line comments, and `/* … */` blocks. A `?` inside any of them is **not** counted as a bind parameter, and an apostrophe inside a comment does not open a string. Two failures name themselves: - `parameter_count_mismatch` — reports both counts and an excerpt of the statement. - `unterminated_sql_literal` — reports the offset where the literal or comment opened, instead of silently swallowing the rest of the statement. Two cases are deliberately unsupported: `NO_BACKSLASH_ESCAPES` (the scan runs before there is a connection to ask about `sql_mode`), and a placeholder inside a version-gated `/*! … */` comment, which is not rewritten. > **Version note.** Until this landed, a question mark in a comment *was* > counted as a placeholder, and an odd apostrophe in a comment swallowed the > rest of the statement. A single rhetorical question mark in a `CREATE TABLE` > comment was enough to make a schema-creation statement fail as > `parameter_count_mismatch`. If you are on an older server and see that error > on a statement whose placeholders plainly match, check the comments. ## Client game namespaces Game namespaces exist on the client host. Calling one on a host without a game backend returns an explicit unavailable error rather than failing silently. This table is the set a client resource reaches for first, not the whole surface: the [API reference](/docs/api) lists every client namespace and every function in it, generated from the runtime rather than maintained here. | Namespace | Permission | Purpose | |---|---|---| | `Open77.character` | none | Read local or registered character state | | `Open77.animations` | none | Play named workspot animations | | `Open77.camera` | `camera.script` | Scripted cameras: create, place, follow, look at, shake, and world-to-screen projection. See [Scripted cameras](/docs/cameras) | | `Open77.input` | `input.actions`, `input.blockAll` | Read a small allowlist of contextual action keys, plus mouse, gamepad and aim state; take a named input away from the player and give it back. See [Blocking player input](/docs/input-blocking) | | `Open77.clipboard` | `clipboard.write` | Write bounded UTF-8 text to the OS clipboard; reading is never exposed | | `Open77.kvp` | none | Persistent typed client storage, isolated by connection address and resource | | `Open77.inspector` | none | Read and highlight the current streamed-world target | | `Open77.nameplates` | `ui.nameplates` | Customise remote-player labels; native per-frame delivery or drawing | | `Open77.blips` | `ui.vanilla.map` | Create resource-owned vanilla mappins | | `Open77.map` | `map.read`, `map.control` | Read player waypoints, pick destinations and customize the native map screen with resource WebUI tabs. See [Native map & waypoints](native-map.md). | | `Open77.markers` | `world.markers` | Create resource-owned 3D world markers | | `Open77.anchors` | ownership of the target page (none for a native `render` style) | Anchor a world point or a followed entity; the plugin projects it every frame | | `Open77.vfx`, `Open77.sfx` | `world.effects` | Resource-owned REDengine visual and spatial audio effects | | `Open77.doors` | `world.doors` | Inspect streamed doors and apply local policy | | `Open77.loot` | `world.loot` | Project authoritative ground loot and the pickup flow | | `Open77.vehicles` | `vehicles.read`, optional `vehicles.presentation` | Read streamed vehicles; guarded remote-occupant presentation | | `Open77.environment` | `world.environment` | Apply authoritative time and weather | | `Open77.travel` | `player.travel` | Local-player noclip and teleport, with a settle report that says when the body really arrived. See [Travel](/docs/travel) | | `Open77.clothing` | `player.clothing.read` / `player.clothing.edit` | The validated local wardrobe | | `Open77.assets` | declared `files` | Resolve resource-owned client assets | | `Open77.webui` / `WebUI` | layer-specific | Create isolated HTML/CSS/JavaScript surfaces | | `Open77.players` | none, `players.life.read` for life state | The other players this client can see: ids, bodies, distances, the nearest one. See [Players around you](/docs/client-players) | | `Open77.state` | none to read, `network.events` to subscribe | Replicated state bags on the server, a player or an entity. A client never writes one. See [State bags](/docs/state-bags) | | `Open77.net` / `Open77.callbacks` | `network.events` | Net events, and callbacks that ask the server a question and await the answer. See [Network callbacks](/docs/callbacks) | | `Open77.hud` | `ui.vanilla.hud` | The vanilla HUD components, the game's own toasts, help text and subtitles. See [HUD visibility](/docs/hud-visibility) | | `Open77.screen` | `screen.effects` | Native fades and transitions, plus an unowned read of the engine's own fade state | | `Open77.map` | `map.read`, `map.control` | The native map: the waypoint, the selected marker, open/close, pick a point | | `Open77.world` | `world.query`, `world.devices` | Raycasts that name what they hit, ground height, nearby entities, the district, ambient population — and taking a vanilla device prompt away | | `Open77.zones` | none | Shared zone geometry from the prelude: normalise a shape, test containment, read bounds | | `Open77.data` | none | Turn a TweakDB record string into a display name, class, manufacturer or seat count | | `Open77.prevention` | `world.prevention` | This client's wanted level and NCPD dispatch. World state, never player state: the units it summons are never replicated | | `Open77.abilities` | `player.abilities.project`, `player.abilities.read` | Project a granted session ability and read its state | | `Open77.chute` | `player.chute` | Arm and disarm the grav-chute | | `Open77.runtime` | none | Register a client command, list the registered ones, run one | | `Open77.session` | none, `session.menus` for menu reads | Session and menu state, including whether a vanilla menu currently owns the screen | | `Citizen`, `vector3`, `promise` | none | The FiveM-shaped aliases, in both runtimes. See [FiveM compatibility](/docs/fivem-compatibility) and [Vectors](/docs/vectors) | When the **server** decides where somebody goes, do not reach for `Open77.travel` at all: `Open77.players.teleport` drives the same machinery, carries the fade and the bucket change, and answers with a promise that resolves only once the client reports the body settled. The kill → respawn transaction is no longer the sanctioned way to move a living player — see [Writing a gamemode](writing-a-gamemode.md#4-move-a-living-player-with-teleport-never-a-transform-write) and [Travel](travel.md). The complete generated signatures for every namespace are in the [API reference](/docs/api). ## WebUI ```lua local page, reason = WebUI.create({ entry = "web/index.html", layer = "menu", -- hud (default) | menu | modal | system | debug width = 1920, -- default 1280, max 8192 height = 1080, -- default 720, max 8192 fps = 30, -- default 30, 1..60 zIndex = 0, transparent = true, -- default true visible = false, -- default true }) if page then local handler = page:on("shop:buy", function(payload, requestId) page:reply(requestId, { accepted = true }, true) end) page:show() page:setFocus(true, true) -- keyboard, cursor page:send("shop:state", { credits = 500 }) end ``` `WebUI.default()` returns the automatically created manifest page, when present. Page handles belong to the resource generation and are destroyed automatically on stop or reload. Page methods are `id`, `show`, `hide`, `destroy`, `setFocus`, `send`, `on`, `off` and `reply`: | Method | Signature | |---|---| | `page:reply` | `(requestId, payload, ok?)` — `ok` defaults to `true` | | `page:setFocus` | `(keyboard, cursor?, keepInput?)` | | `page:setConsumedKeys` | `(keys)` — selectively consume navigation keys while retaining gameplay input; requires the updated client. | | `page:send` | `(event, payload?)` — event name at most 128 bytes | | `page:on` | `(event, handler)` — returns a handler id | | `page:off` | `(handlerId)` | `page:reply` takes the payload second and success flag third. `page:setFocus` takes the keyboard flag first. `page:off` and `Open77.events.off` accept only a handler ID, not an event name. The JavaScript bridge is exposed as `window.Open77`: ```js Open77.on('shop:state', state => render(state)); Open77.emit('shop:hover', { item: 'medkit' }); const result = await Open77.invoke('shop:buy', { item: 'medkit' }); Open77.ready(); ``` Bundled WebUI uses an isolated virtual HTTPS origin; remote WebUI uses the configured HTTP(S) origin. Lua/JavaScript payloads use a bounded JSON codec. ### Shared assets between resources Use `open77://RESOURCE/PATH` to load a file declared in another running resource's `files` or `web_files` list. A library can contain only assets, with no scripts or WebUI of its own. Add a dependency in consumers to establish startup order. ```html Water ``` This requires a client with shared asset protocol support. The URL reads the downloaded file directly; no Lua event or base64 transfer is needed. See [Shared WebUI assets](webui-assets.md) for complete manifests, stylesheets, modules, fonts, data, lifecycle behavior and troubleshooting. ### WebUI tabs in the native map On clients with map-tab support, `Open77.map.addTab({id, label, url})` creates a resource-local page inside the native map's content area. Use `page` instead of `url` to register an existing owned, hidden and unfocused page. Register after resource preparation with `map.control`. The screen manages focus, visibility and bounds: bound pages cannot use `show`, `hide` or `setFocus` directly. Use `Open77.map.selectTab` to activate an owned tab on an open map. Listen to Lua `open77:map:tabEntered` / `tabLeft` or JavaScript `open77:map:tabState` for lifecycle updates. JavaScript should install listeners before emitting `open77:map:ready` to receive the current state. Pages retain state between visits; pause timers and audio when inactive. See [Native map customization](native-map.md#customize-the-map-screen) for the complete example, title/accent overrides, limits and cleanup. ### Escape and selective keyboard consumption `page:setFocus(true, false, true)` intentionally keeps native gameplay input alive. Browser `preventDefault()` and `stopPropagation()` cannot stop the native Raw Input stream, so Escape can also open Cyberpunk's pause menu. `Open77.session.setMenuReady` is platform pause readiness, not a per-page key blocker. On the updated client, configure the page before taking focus: ```lua -- Manifest permission: "webui.keep_input" assert(page:setConsumedKeys({"escape"})) assert(page:setFocus(true, false, true)) -- When the menu closes: page:setFocus(false, false) page:hide() ``` The browser receives Escape without opening native pause or Open77 pause. Movement and mouse look remain active. See [WebUI keyboard input](webui-input.md) for keys, examples, errors and focus transfer. This does not consume a controller's menu button or disable pause globally. ### Remote pages, external content and hot reload Remote pages require HTTP(S) entry support in the client. Browser security rules still apply to remote content. `ui_page` (alias `web_ui_page`) and `WebUI.create({ entry = ... })` accept an HTTP or HTTPS URL. No host allowlist or extra network permission is required for WebUI. A remotely hosted page needs no `web_files` declaration: ```lua -- open77.lua resource "my_ui" version "1.0.0" ui_page "https://ui.example.dev/" client_script "client.lua" ``` ```lua -- client.lua: the manifest page is automatically created. CreateThread(function() local page = assert(WebUI.default()) page:on("shop:buy", function(payload, requestId) -- Validate requests here, and validate purchases on the server. page:reply(requestId, { accepted = true }) end) end) ``` For Vite/Vue/React/Nuxt development, use `http://localhost:5173/` as the entry, or create a page explicitly with `WebUI.create({entry = "http://localhost:5173/", layer = "menu"})`. Set `web_ui_auto_create false` if creating the manifest page manually. **localhost is the player's PC**, not the game server. Other testers need a reachable hostname/IP or a development tunnel. Configure the dev server's host and WebSocket URL to be reachable by those clients; Cloudflare must proxy WebSockets for HMR. A page reload reinjects the JS bridge. Register UI listeners and call `Open77.ready()` again so Lua can resend the initial state. Bundled and remote pages can load external scripts, styles, fonts, images, audio/video, HTTP(S) fetch/XHR, WS/WSS and iframe embeds. HTTP development content is allowed even from a bundled HTTPS page. For example: ```html ``` This removes **Open77's external-content block**, not the remote site's rules: - CORS still applies to fetch/modules/fonts. Configure the API's allowed origin, or use your web server's same-origin proxy. Do not ship API secrets in browser JavaScript. - A site's own CSP, `frame-ancestors`, `X-Frame-Options`, login requirements and certificate validity still apply. Use its supported embed URL, not an ordinary watch page. YouTube also requires an HTTP Referer; do not set `no-referrer`. Media playback depends on codecs available in the bundled CEF. - The Lua bridge is exposed only to the top-level page at the configured entry's origin (scheme + host + port). External iframes receive no bridge; cross-origin top-level navigation can display a site, but loses Lua access until returning to the configured origin. Same-origin redirects and reloads preserve it. Use `postMessage` with an exact origin check for trusted embeds. - Files served through the page's own virtual origin require `web_files`. To read another resource's declared `files` or `web_files`, use the [shared asset protocol](webui-assets.md). Another resource's private virtual origin, `file:` URLs, OS protocol execution, plugins and desktop popups stay blocked. Layer permissions, message quotas and resource teardown remain enforced. - Remote content is downloaded by **each player's browser**, not included in the signed resource archive. HTTPS is recommended in production. Remote hosts and third-party embeds can see client IPs and browser requests; a compromised script in the main page has that page's Lua bridge access. Request contexts are isolated per surface and are not persistent login profiles. HTTP errors are reported as `webui_http_status:`; DNS/TLS/connection errors remain visible as Chromium errors in WebUI diagnostics. CORS and embedding-policy errors appear in the WebHost browser console log. | Symptom | What to check | |---|---| | A dev URL works on your PC but not another player's | `localhost` points at each player's own PC. Use a reachable host; check the dev server's listening address and firewall. | | The page loads but HMR does not reconnect | Verify the WebSocket URL/port and proxy upgrade support. HTTPS pages normally use WSS. | | The page loads but `window.Open77` is missing | Check the final top-level URL. An HTTP-to-HTTPS or host/port redirect changes origin; configure the final URL as the entry. Iframes never receive the bridge. | | An API call is blocked by CORS | Allow the page's origin on that API, or proxy it through the page's own server. WebUI does not bypass CORS. | | A video or iframe refuses to display | Use the provider's embed URL, preserve its required Referer and inspect CSP, embedding, login and codec errors. | | The page's scale changes on Ctrl + wheel | Install the updated client. It suppresses CEF's wheel zoom, not zoom implemented by the page's own JavaScript. | Embedded WebUI pages do not use CEF's Ctrl + mouse wheel browser zoom: Ctrl + wheel scrolls normally, without changing the page scale. This is not a promise to disable keyboard shortcuts or custom page zoom code. This WebUI network policy does **not** change the dedicated server's Lua HTTP bridge, its `http.request` permission or its host allowlist. The `modal`, `system` and `debug` layers require `webui.modal`, `webui.system` and `webui.debug` respectively; the `hud` and `menu` layers are ungated. Keeping game input active while a page owns focus requires `webui.keep_input`, which is the third argument to `setFocus`. System and debug capabilities are intended only for audited OPEN//77 resources. A page is the wrong tool for anything that must sit *on* a world point every frame — see [Drawing in the world](world-drawing.md#native-overlays--card-ring-dot). ## Sandbox and quotas Default client limits apply per resource, except the frame budget, which is shared by the client host. Clients without these capabilities may use lower limits; require the appropriate runtime for resources that depend on them. | Limit | Value | |---|---| | Lua memory | 96 MiB | | Instructions per coroutine resume | 1,500,000 | | Lua frame budget (host-wide) | 6 ms per client host | | Scheduled tasks | 3,072 | | Event handlers | 6,144 | | WebUI handlers | 1,536 | | WebUI surfaces | 24 | | Lua source size | 12 MiB per file | | `readFile` result | 3 MiB | | Lua files in a resource | 3,072 (including `open77.lua`) | | Declared `files` / `web_files` | 6,144 each | The frame budget is shared by all resources within a client host, not granted to each resource. The trusted and server-downloaded hosts each have this ceiling. These are maximum allowances, not reserved memory or a higher tick rate. The dedicated server's Lua runtime has separate limits. The signed-manifest and transport/message size caps have not been tripled. For example, `readPackedFile` can read up to 3 MiB of raw file data, but its Base64 result may exceed the unchanged 2 MiB WebUI message limit. Do not use the larger file allowance as a promise that one `page:send` can carry it. Exceeding the instruction budget raises `Open77 script execution budget exceeded` from the instruction hook. That is a Lua error: it unwinds straight out of the coroutine body, and a `while true do ... Wait(n) end` loop that hits it **never resumes again**. If a long-running loop silently stops, suspect the budget before suspecting the logic. The sandbox does not expose `io`, `os`, `debug`, `package`, `dofile`, `loadfile`, `load`, arbitrary native modules, FFI, or bytecode loading. Direct coroutine creation and resumption are removed; public coroutines are owned by the OPEN//77 scheduler. These limits are safety boundaries, not performance targets. Keep frame callbacks short, yield during multi-step work, and move authority and expensive operations to the server. ## Reload and distribution Resource updates are transactional: ```text current generation remains active -> parse and hash candidate -> create isolated candidate VM -> compile and prepare entry points -> failure: discard candidate, keep the running generation -> success: stop old generation and commit the new one ``` The server builds and signs a new client resource set when applicable. Clients download the missing content, verify signatures, manifests, paths, chunks and file hashes, then switch to the complete generation. **A partial or invalid set is never activated.** Server console commands: ```text resources refresh ensure start stop restart reload ``` Client diagnostics: ```text resource.root resource.distribution resource.list resource.status ``` A hot reload is the first time a `.lua` file's syntax is validated end to end, so a typo can take a resource down mid-session. Parse your Lua before it reaches a live client — with the exact Lua 5.4.8 the runtime embeds, rather than a different version that could pass or reject the wrong things. `open77.lua` manifests must be skipped by any such check: the manifest is a declarative DSL parsed by a bespoke reader, never by the Lua compiler, so its call sugar (`auto_start true`) is intentionally not valid standalone Lua. ## Boot commands: `startup.commands` A server can pin its own world state at boot with a list of console commands in `server.jsonc`. This is how a server sets its weather, or anything else that is a one-off command rather than a config field. ```jsonc "startup": { "commands": [ "weather.time.set 20:30:00", "weather.set lightclouds", "weather.random off" ] } ``` At most 64 entries, each 1–512 characters and free of control characters. **When they run.** After every `auto_start` resource has reached Running — so the commands they register exist — and after those resources have had one scheduler slice, so anything they initialise lazily on their first tick has happened. They run before the console and the admin panel are drained on that tick, so the configured list wins over anything typed at the same moment. Once per process. They run with **console authority**: the same path as typing at the server console, `source = 0`, restricted resource commands allowed, and never a player identity. Keep the list credential-free — it is plain text in the config. **A refused line is reported, never fatal.** Every line is dispatched; a refusal is logged with its line number, and a closing summary names the refused lines and says the server started anyway. Boot is not aborted. One subtlety: a command registered by a Lua resource is only *queued* onto that resource's scheduler, so the immediate reply is "queued by resource X". A bad *argument* surfaces a line or two later, prefixed with the resource name. **Restart re-asserts; reload does not need to.** A resource that reloads carries its authoritative state across the new VM, so the pin survives and nothing is replayed — which also preserves anything an operator changed at the console since boot. A resource that went down and came back carries nothing, so the host re-dispatches exactly the boot lines that resource owned and that did not fail the first time. Lines owned by another resource, or by nobody, are untouched. Changing the list itself still requires a server restart. ## API conventions **Engine IDs are opaque.** REDengine identifiers are 64-bit values and may not be exactly representable as Lua numbers. Store, compare and return them unchanged. Do not pass them through `tonumber`. (Player *session* IDs are a different thing: small integers, and they arrive from events as strings, so those you *should* `tonumber`.) **Failures are values.** Most APIs return `value` on success or `nil, reason` on failure, so a caller can distinguish an invalid request from a temporarily unavailable subsystem without wrapping every call in `pcall`. **Permissions are explicit.** Guarded APIs require a manifest permission and answer `permission_denied:` without it. Request only what the resource needs. **Client and server runtimes are separate.** `server_script` files never reach a player's machine; `client_script` files are distributed in the signed set; `shared_script` files execute in both. ## Security guidelines - Keep secrets, database credentials and authoritative validation in server scripts. - Treat every client event and argument as untrusted input. - Grant only the permissions a resource needs. - Never commit resource-signing private keys or runtime cache directories. - Handle `value, reason` and `boolean, reason` results instead of assuming a game service is available. - Clean up explicitly where practical, even though generation shutdown provides a final ownership cleanup. ## See also - [Server resources](server-resources.md) — the connection flow, signing and operational configuration. - [Complete server Lua API](server-api.md) — the dedicated-server surface. - [Official resource exports](resource-exports.md) — every export published by the official packages. - [Drawing in the world](world-drawing.md) — markers, anchors, POIs and zones. - [The join-time readiness gate](readiness-gate.md) — the rule before you act on a joining player. - [Operator tunables](tunables.md) — settings a server owner moves live. - [Writing a gamemode](writing-a-gamemode.md) — putting it together. --- Source: https://open2077.net/docs/lua-modules # Lua modules and require Split resource code into modules with `require`. A module returns its public value, typically a function table. Libraries such as [PolyZone](polyzone.md) execute inside the importing resource. ```lua local PZ = assert(require('@polyzone')) local zone = PZ.CircleZone:Create({x=100, y=200, z=20}, 15, {name='meeting_point'}) print(zone:isPointInside({x=105, y=200, z=20})) -- true ``` This loads PolyZone into **your resource's client Lua VM**. It does not download a package from the internet, call a remote service, or share your zones with another resource or player. ## Availability `require` is a **client-side** global. The dedicated-server sandbox does not expose it. For server code, declare ordered `server_script` entry points; use [server exports](server-exports.md) to call another server resource. Local modules already exist. The `@dependency` syntax requires the newer client implementation introduced with PolyZone in client `2.31.13+op77.67`. Installing a Lua package alone cannot add this capability to an older client. ## Load a file from your own resource Example resource layout: ```text taxi/ open77.lua client/main.lua shared/fares.lua ``` Publish the module as a downloadable file, not a second entry point: ```lua -- taxi/open77.lua resource 'taxi' version '1.0.0' client_script 'client/main.lua' files { 'shared/fares.lua' } ``` ```lua -- taxi/shared/fares.lua local Fares = {} function Fares.calculate(metres) return math.floor(10 + metres * 0.02) end return Fares ``` ```lua -- taxi/client/main.lua local Fares, reason = require('shared.fares') assert(Fares, reason) print(Fares.calculate(1500)) -- 40 ``` The name is relative to the **resource root**, not to `client/main.lua`. Use dots for directories and omit `.lua`. | Import | Resolution, in order | |---|---| | `require('shared.fares')` | Your resource's `shared/fares.lua`, then `shared/fares/init.lua` | | `require('@my_library')` | The dependency's published `init.lua`, then `init/init.lua` | | `require('@my_library/helpers.math')` | The dependency's published `helpers/math.lua`, then `helpers/math/init.lua` | Do not write `require('shared/fares.lua')` or an absolute disk path. Module names accept letters/digits, underscores, hyphens and dot-separated segments; traversal such as `..` is rejected. The slash after `@my_library` separates the resource name from the module name; nested module directories still use dots. `files` makes a file available to clients. It does not execute it. Conversely, `client_script` executes an entry point automatically. Listing the same module as an entry point and importing it can execute its initialization twice. ## Import a dependency such as PolyZone The server must install and load both resources. The caller declares its dependency and the permissions its own code needs: ```lua -- my_job/open77.lua resource 'my_job' version '1.0.0' dependency 'polyzone >=1.0.0' client_script 'client/main.lua' permissions { 'world.query' } ``` ```lua -- my_job/client/main.lua local PZ, reason = require('@polyzone') assert(PZ, reason) local garage = PZ.BoxZone:Create({x=100, y=200, z=20}, 12, 8, { name='garage', heading=30, minZ=19, maxZ=24, }) garage:onPlayerInOut(function(inside) print(inside and 'Entered garage' or 'Left garage') end, 250) ``` Here `world.query` is needed to read the player's position, not to use `require` itself. A purely mathematical module needs no engine permission. Add `world.debug` only if you enable PolyZone debug drawing. The dependency must be declared and running, and its module must be published under `files`. `require` neither installs nor starts a missing dependency. Imports always execute with the **caller's permissions, memory budget and resource lifetime**, not the provider's privileges. ## Publish your own library ```lua -- my_library/open77.lua resource 'my_library' version '1.0.0' auto_start true files { 'init.lua', 'helpers/math.lua' } ``` ```lua -- my_library/helpers/math.lua return { double = function(value) return value * 2 end } ``` ```lua -- my_library/init.lua return { math = assert(require('@my_library/helpers.math')) } ``` Use the provider-qualified form for sibling imports inside a reusable library. `require('helpers.math')` would search the **consumer's** resource, even when that call is written inside `my_library/init.lua`. The consumer declares `dependency 'my_library >=1.0.0'` and uses `local Library = assert(require('@my_library'))`. It does not list `@my_library/init.lua` as a manifest script. A provider may use its own `@my_library` name without declaring a dependency on itself. Everything in `files` is client-distributed content. Never publish credentials, private configuration or server-only logic there. ### A library needs no script of its own The manifest above is complete. `files` alone is a valid resource, and so is `files` plus a `server_script` for a library that also publishes server-side `exports()`: ```lua -- bt_lib/open77.lua: server exports plus client modules, no client entry point resource 'bt_lib' version '0.1.0' auto_start true server_script 'server/main.lua' files { 'init.lua', 'client/raycast.lua', 'client/format.lua' } ``` The server delivers every resource that has client content, and `files` is client content. On the client such a **passive library** gets an idle VM, reaches `Running` without executing anything, and a dependant's `require('@bt_lib/...')` resolves against its declared files. A `server_script` never reaches a client; it does not have to be paired with a `client_script`, empty or otherwise. The only manifest that is refused is one with neither a script nor `files`: `no_client_scripts` on the client, "Resource contains no scripts or files." on the server. `web_files` alone do not count; nothing could open them. **Builds up to 2.31.13+op77.82 are stricter.** Their client refuses any delivered resource without a `client_script` or `shared_script`, logs `no_client_scripts`, and that single refusal fails the whole activation: `server resource activation failed: discovery:... failed=1`, and the client stays on "Verifying resources". Fixed in the release that follows op77.82. Until your players run it, add an empty `client_script 'client/noop.lua'` to the library; it costs nothing and can be removed afterwards. ## Cache, ownership and reload The first successful import executes the module synchronously. Later imports of the same resolved file in the same Lua VM return its cached value: ```lua local first = assert(require('@polyzone')) local again = assert(require('@polyzone/init')) print(first == again) -- true: both resolve to polyzone/init.lua ``` If a module returns nothing or nil, the cached result is `true`. Return a table explicitly when exposing an API. A module may deliberately return `false`; check `value == nil` if false is a valid result for your library. Two consuming resources have **separate module instances**. Within one VM, mutating the returned table affects other imports of that module in that VM; it does not affect another resource's copy. Keep module initialization synchronous: do not call `Wait` or await an export at file scope during `require`. Return functions that can later run in scheduler coroutines. Circular imports return `nil, 'circular_module_dependency'`. Stopping/reloading the consumer destroys its VM, cached modules and owned tasks/handlers. Native objects still follow their individual API cleanup rules; PolyZone clears its own blips and debug drawings. Dropping one Lua variable is not equivalent to destroying a zone: call `zone:destroy()` when finished. Updating the provider does not rewrite tables already held by consumers. Reload the consuming resources to adopt the new library code. A fresh `require('@provider')` still checks that the dependency is declared and running; a cached value does not bypass those checks. ## Require or exports? | Need | Use | Where the code runs | |---|---|---| | Split your client code into files | Local `require` | Your resource's VM | | Reuse constructors, helpers, zones or callback-based Lua objects | `require('@library')` | A separate library copy in each caller's VM | | Ask another resource to open its UI or perform a service operation | `Open77.exports.call(...)` | The provider's VM, asynchronously | | Send information between client and server | Network events | Across the transport, with server validation | Exports copy supported values across the VM boundary; they cannot transfer a live Lua function or a working zone object with methods. That is why PolyZone uses `require` for its classes. Its server-side `TriggerZoneEvent` service is an export instead. See [resource exports](resource-exports.md) and [server exports](server-exports.md). Imported client code does not become authoritative. For money, inventory or access checks, validate the action on the server even when a client zone says the player is inside. ## Troubleshooting ```lua local library, reason = require('@my_library') if library == nil then print('Library could not be loaded: '..tostring(reason)) return end ``` | Result | What to check | |---|---| | `module_dependency_not_declared` | Add the provider's resource name to the caller's `dependency` declaration. | | `module_dependency_not_running` | Install/load the provider and fix any startup error before importing it. | | `module '...' not found in resource VFS` | Check spelling, resource-root paths, downloaded files and the provider's `files` entries. | | `invalid_module_name` | Omit `.lua`, use dot-separated folders, and avoid paths/URLs. An old client can also reject the new `@` syntax. | | `circular_module_dependency` | Remove the import cycle; extract shared definitions into a third module. | | Syntax/runtime error text | Fix the module identified in the message; its first execution did not complete. | | Attempt to call nil `require` | This is server-side code, not the client module runtime. | Ordinary module lookup/loading failures return `nil, reason`; invalid argument types can raise a Lua argument error. No `package.path`, native DLL loader, HTTP importer or unrestricted filesystem access is exposed. See the [client API reference](/docs/api/client/globals#require) for the call signature and [PolyZone](polyzone.md) for the complete zone API. --- Source: https://open2077.net/docs/vectors # Vectors and quaternions `vector2`, `vector3`, `vector4`, `vec` and `quat` are global constructors in both Lua runtimes. They provide arithmetic, magnitude (`#`), swizzles, distance helpers and quaternion operations: ```lua if #(playerPos - jobPos) < 3.0 then -- in range end ``` The expression works in both runtimes. ## Plain-table compatibility **A vector *is* a plain Lua table carrying `x`, `y`, `z` (and `w`) as real fields.** The only thing a constructor adds is a shared metatable. Nothing about the table changed: ```lua local v = vector3(1, 2, 3) type(v) --> "table" rawget(v, "x") --> 1 for k in pairs(v) do print(k) end --> x, y, z ``` Three consequences, all of them deliberate: - Every API that accepts a `{ x =, y =, z = }` table today accepts a vector, with no change to the API and no change at the call site. The client's native position readers and the server's `type(p.x) == "number"` guards never had to learn a new type, because there is no new type to learn -- only new behaviour on a familiar one. - Every API that returns a position **still returns a plain table**. Nothing about a return shape changed in this wave; changing one would break every resource reading `pos.x`. Wrap it when you want the maths: `vector3(pos)`, or `vec(pos)`. - Serialisation is unchanged too, so `json.encode`, net events and exports carry a vector as the object it always was. See [JSON and the network](#json-and-the-network). Mixing the two freely is the expected style: ```lua local here = Open77.character.position() -- a plain table, as always local there = vector3(1660.0, -723.6, 50.5) local metres = #(there - here) -- one vector operand is enough ``` ## Constructors | Call | Result | |---|---| | `vector2(x, y)` | a 2D vector | | `vector3(x, y, z)` | a 3D vector | | `vector4(x, y, z, w)` | a 4D vector | | `vec(...)` | dispatches on the argument count: 2, 3 or 4 numbers | | `quat(x, y, z, w)` | a quaternion | Every constructor also accepts **one table** -- another vector, or any `{ x =, y =, z = }` table -- and copies it: ```lua vector3({ x = 1, y = 2, z = 3 }) --> vector3(1.0, 2.0, 3.0) vector3(someVector4) --> takes x, y, z vec(Open77.character.position()) --> a vector3, because the table has three axes ``` `vec` with a single table picks the size from the axes that are actually there: `w` present means `vector4`, else `z` means `vector3`, else `y` means `vector2`. Components must be finite numbers. A constructor that cannot build a vector returns `nil, reason` -- never a half-built value: ```lua local v, reason = vector3(1, 2) --> nil, "invalid_vector" local q, reason = quat(1, 2, 3) --> nil, "invalid_quat" ``` ## Fields and swizzles `v.x`, `v.y`, `v.z` and `v.w` are ordinary field reads with no metamethod in the way. Any combination of two to four axes the vector actually has is a swizzle, returning the vector of that size: ```lua local v = vector4(1, 2, 3, 4) v.xy --> vector2(1.0, 2.0) v.xz --> vector2(1.0, 3.0) v.yz --> vector2(2.0, 3.0) v.xyz --> vector3(1.0, 2.0, 3.0) v.zyx --> vector3(3.0, 2.0, 1.0) vector3(1, 2, 3).xw --> nil, there is no w ``` ### Read-only fields and cached length A vector is a **value**. Assigning any key raises: ```lua local v = vector3(1, 2, 3) v.label = "spawn" -- error: Open77 vectors are read-only (tried to set 'label') ``` **The exception is the component keys themselves.** `v.x = 9` succeeds, silently, and there is no way to stop it: Lua 5.4 only calls `__newindex` for a key that is *absent* from the table, and `x`, `y`, `z` and `w` must be physically present or every existing reader -- `json.encode`, net-event serialisation, `pairs`, `rawget`, the native C++ and C# position parsers -- would stop seeing a vector as the table it has always accepted. That compatibility was worth more than the guard, so the guard is documented rather than faked. Treat components as immutable anyway. To change one, build a new vector: ```lua local raised = vector3(v.x, v.y, v.z + 1.0) ``` `v:table()` gives you a fresh, ordinary, mutable copy when you want one. ## Operators | Operator | Meaning | |---|---| | `a + b`, `a - b` | component-wise; a number broadcasts to every component | | `a * b`, `a / b` | component-wise by a vector, or scaled by a number | | `-a` | negation | | `#a` | magnitude (`a:length()`) | | `a == b` | exact, and only between two Open77 values of the same kind | | `a .. b`, `tostring(a)` | `vector3(1.0, 2.0, 3.0)` | The other operand of an arithmetic operator may be a vector, a plain `{ x =, y =, z = }` table, or a number: ```lua vector3(1, 2, 3) + vector3(4, 5, 6) --> vector3(5.0, 7.0, 9.0) vector3(1, 2, 3) + { x = 1, y = 1, z = 1 } --> vector3(2.0, 3.0, 4.0) vector3(1, 2, 3) * 2 --> vector3(2.0, 4.0, 6.0) { x = 1, y = 1, z = 1 } + vector3(1, 2, 3) --> vector3(2.0, 3.0, 4.0) ``` **Operators raise; functions return `nil, reason`.** An operator has no second return slot, so yielding a silent `nil` from `a + b` would hide the bug instead of naming it. Mixing dimensions, handing an operator something that is not a vector, and producing a non-finite component all raise: ```lua vector2(1, 2) + vector3(1, 2, 3) -- error: vector addition between a vector2 and a vector3 vector3(1, 2, 3) + "text" -- error: vector addition requires a vector, ... or a number ``` `==` is exact and kind-aware. `vector3(1, 2, 3) == vector3(1.0, 2.0, 3.0)` is true (Lua numbers compare by value), while `vector3(1, 2, 3) == { x = 1, y = 2, z = 3 }` is **false**: a plain table has no kind, and guessing one would make `==` depend on which keys a table happens to carry. Compare components, or wrap the table first. `quat` deliberately has **no** arithmetic and no `#`. It has three methods and nothing more. ## Methods Every method that takes another vector also accepts a plain `{ x =, y =, z = }` table. | Method | Returns | |---|---| | `v:length()` | magnitude; same as `#v` | | `v:lengthSquared()` | magnitude squared -- the one to compare against a squared radius | | `v:distance(other)` | distance to `other` | | `v:distanceSquared(other)` | squared distance | | `v:normalize()` | unit vector; **a zero vector normalises to zero, never to NaN** | | `v:dot(other)` | dot product | | `v:cross(other)` | cross product, `vector3` only; otherwise `nil, "cross_requires_vector3"` | | `v:lerp(other, t)` | linear interpolation, **unclamped** -- `t` outside `0..1` extrapolates | | `v:unpack()` | the components as multiple return values | | `v:table()` | a fresh, plain, mutable `{ x =, y =, z = }` table | ```lua local distance = playerPos:distance(target) --> number local nearby = vector3(playerPos):lengthSquared() < 25.0 local nothing, reason = vector3(1, 2, 3):dot(42) --> nil, "invalid_vector" ``` ### Quaternions `quat` exists because the codebase already hands `{ x =, y =, z =, w = }` orientations to `Open77.effects` and `Open77.props`. It makes that table computable and stops there. | Method | Returns | |---|---| | `q:normalize()` | unit quaternion; a **zero** quaternion normalises to the identity `(0, 0, 0, 1)` | | `q:mul(other)` | Hamilton product: this rotation followed by `other` | | `q:rotate(v)` | a `vector3` rotated by `q`; assumes `q` is unit, so `normalize()` first when unsure | ```lua local spin = quat(0, 0, 1, 0) -- half turn about Z spin:rotate(vector3(1, 0, 0)) --> vector3(-1.0, 0.0, 0.0) Open77.effects.create({ effect = "fire.small", position = vector3(7, 8, 9), orientation = spin }) ``` ## JSON and the network Because a vector is a table with real `x`, `y`, `z` fields, it serialises as the JSON object it looks like, with no extra members: ```lua json.encode(vector3(1, 2, 3)) --> {"x":1,"y":2,"z":3} ``` Member **order** is whatever the encoder's table walk produces, as for any Lua table; only the members and their values are guaranteed. Nested vectors serialise the same way, so a table of waypoints encodes correctly with nothing special at the call site. The same reader serves `json.encode`, network events and cross-resource exports. So a vector sent over the wire arrives on the other side as a **plain table**, not a vector -- the metatable does not cross the network, and no other runtime's values ever do. That is correct and intentional; wrap it on arrival when you want the maths back: ```lua RegisterNetEvent("job:waypoint", function(point) local target = vector3(point) -- point arrived as { x =, y =, z = } if #(Open77.character.position() - target) < 5.0 then ... end end) ``` ## One implementation, two runtimes The type is written once, in Lua, in `scripting/lua/open77_vector.lua`. The client embeds that file as a C++ string literal generated at configure time; the dedicated server embeds the very same file as an assembly resource. There is no second copy to drift. Both runtimes prove it the same way: `scripting/lua/open77_vector_parity.lua` runs on each and its transcript is compared against one shared golden file, `scripting/lua/open77_vector_parity.expected.txt`. A semantic difference between client and server can only appear as a failing test. Every metatable is protected with `__metatable`, so `setmetatable` on a vector raises and `getmetatable` hands back only the kind tag (`"Open77.vector3"`), never the metatable itself. That matters on the server, where those two globals are not sandboxed away as they are on the client. ## Failure vocabulary | Reason | Meaning | |---|---| | `invalid_vector` | the argument was not a vector, nor a table with that many finite numeric axes | | `invalid_quat` | same, for `quat` | | `invalid_argument` | `lerp` was given a non-finite `t` | | `cross_requires_vector3` | `cross` was called on a `vector2` or `vector4` | --- Source: https://open2077.net/docs/server-exports # Cross-resource server exports Publish and call cross-resource server functions synchronously with `exports.resource:name(...)` or asynchronously with `Open77.exports.call(...)`. Each resource retains its own Lua VM; calls do not share globals, functions, handles or mutable tables. Client and server export registries are separate. A server export is neither a client RPC nor a network event. ## Publish a service Create `resources/score_service/open77.lua`: ```lua resource 'score_service' version '1.0.0' server_script 'server.lua' ``` Then `resources/score_service/server.lua`: ```lua local scores = {} exports('add', function(playerId, points) -- Identity comes from the runtime, never from a supplied owner string. if GetInvokingResource() ~= 'race_mode' then return nil, 'caller_denied' end if type(playerId) ~= 'number' or playerId < 1 or playerId % 1 ~= 0 then return nil, 'invalid_player' end if type(points) ~= 'number' or points % 1 ~= 0 or points < 1 or points > 100 then return nil, 'invalid_points' end scores[playerId] = (scores[playerId] or 0) + points return scores[playerId] end) ``` This is an in-memory example, not persistent storage or a race-result validator. The gamemode must verify the player's result before awarding points. The service must validate every public argument even when it recognizes the caller. ## Call it from another resource `resources/race_mode/open77.lua`: ```lua resource 'race_mode' version '1.0.0' dependency 'score_service >=1.0.0' server_script 'server.lua' ``` `resources/race_mode/server.lua`: ```lua -- Restricted demonstration command: do not expose arbitrary score grants. RegisterCommand('score_demo', function(playerId) if playerId == 0 then print('Run this as an authorized player'); return end local pending, dispatchError = Open77.exports.call('score_service', 'add', playerId, 10) if not pending then print(dispatchError); return end local score, callError = pending:await() if score == nil then print(callError); return end print('New score: ' .. score) end, true) ``` ## Or call it synchronously The FiveM spelling works too, and returns the export's values directly instead of a Promise: ```lua local score = exports.score_service:add(playerId, 10) local stored = exports['open77-garage']:store(vehicleId) ``` A resource name with a hyphen only works through the bracket form, exactly as in FiveM; `exports.res.name(...)` with a dot works as well, because the proxy recognises and drops the `self` the colon inserts. `Open77.exports.callSync(resource, name, ...)` is the same path without the sugar, for when the export name is in a variable. The call runs **inline on the host scheduler thread**. Every server resource VM already runs on that one thread, and the scripting host holds no lock while a VM executes, so entering a second VM from inside the first is a plain nested call: there is nothing to contend for, and therefore nothing that can deadlock. The callee's body finishes before the call expression returns — no queue, no tick boundary, no Promise. Four consequences, and each is worth knowing before choosing this form: - **The callee must not yield.** `Wait`, `:await()` and the MySQL `.await` helper fail the call with `export_yielded`: there is no scheduler underneath a synchronous callee to yield to. That is the dividing line between the two forms — if the callee can be slow, or touches the database, network or HTTP, use `Open77.exports.call`. - **Failure raises instead of returning `nil, reason`.** This is deliberate and it is the one place Open77 breaks its own convention. FiveM raises, ported resources are written for that, and a raise is the only way to keep "the export returned nil" distinguishable from "the call never happened". Wrap it in `pcall` when you want to decide for yourself. The asynchronous form keeps `nil, reason` unchanged. - **Recursion is capped at 8 frames.** A → B → A works and is supported; the ninth nested frame fails with `export_recursion_limit`. The cap exists because each frame costs a real host stack frame across two Lua states, and Lua's own C-stack guard only counts frames inside one state. - **A resource may call its own export.** It is an ordinary nested call, arguments and results still copied, and it counts toward the cap. Unlike the asynchronous self-call, which is deferred to the next tick, this one runs now. Budgets stay where they belong. The callee runs with **its own** instruction counter, reset for the call and restored after it, so a callee that burns its budget fails that one call with `export_budget_exhausted` and leaves the caller running. `source` is cleared for the duration and restored afterwards, so a synchronous export is no more proof of a player's identity than an asynchronous one — including on a self-call, where both sides share the same VM global. Enable `race_mode` in the server's resource selection; its dependency starts the service first. Keep the existing resources enabled. The restricted command uses the normal `command.score_demo` ACL. A declared dependency controls ordering and stop propagation, **not authorization**: any running server resource can attempt an exported call. The service decides which callers it accepts. Use `CreateThread`, an event handler or a command handler for `:await()`. Do not call exports at file scope while a candidate VM is preparing: the call returns `nil, 'resource_preparing'`. Registering exports at file scope is supported. Start initial requests from a scheduled thread or `onResourceStart` instead. ## Execution, identity and permissions - `exports(name, function)` publishes or replaces a function in this VM. - `Open77.exports.call(resource, name, ...)` returns a Promise or `nil, reason`. - `exports.resource:method(...)` and `Open77.exports.callSync(resource, name, ...)` run the callee inline and return its values, or **raise**. See the synchronous section above; `exports` is both the registration call and the proxy, and neither it nor a proxy can be assigned to. - `promise:await()` returns the function's values, including multiple values and nil holes. A rejected call returns `nil, reason`; `promise:status()` reports `pending`, `resolved`, `rejected` or `cancelled`. A service may also deliberately return `nil, reason` as a normal result (Promise status then remains `resolved`). - Calls execute on the target's scheduler, not inside the caller's native stack. Exported functions may `Wait`, await another export, or await supported MySQL operations. Self-calls and nested calls are also deferred. - `GetCurrentResourceName()` remains the provider. `GetInvokingResource()` and `GetInvokingResourceGeneration()` identify the immediate caller throughout the exported coroutine, including after a yield. Outside that coroutine they are nil; a new thread/timer does not inherit the identity. Nested A → B → C calls identify B as C's caller. `GetCurrentResourceGeneration()` identifies this VM. - Native permissions, filesystem roots, database ownership and gameplay handles remain those of the **provider**. A privileged caller does not lend capabilities to an unprivileged provider. Conversely, a privileged provider must explicitly authorize callers before offering privileged operations. - The export callback does not inherit a network player's `source`; it begins each resume with `source == nil`. Pass a validated player ID explicitly. An export is a resource-to-resource call, not proof of a player's identity. Keep resource-owned handles in the provider. Return a serializable ID and expose validated methods for operating on it; never attempt to transfer a native handle or function. For long-lived ownership, retain both caller name and generation, and remove stale ownership when the resource stops/reloads. Use `Open77.resource.generation(ownerName)` to compare with the stored generation: it changes on reload and returns `0` if the provider is no longer running. With no argument it returns this VM's generation. These identities are local to the server process, not durable IDs to persist across a server restart. Exports and events are different tools. `TriggerEvent` publishes on the host-wide bus (every running resource that handles the name receives it on the next tick, see [the host-wide event bus](server-api.md#the-host-wide-event-bus)); an export is a synchronous call with a return value. Client/server communication still uses authenticated network events. ## Values, limits and lifecycle Arguments and results support nil, booleans, finite numbers, 64-bit Lua integers, binary strings and nested plain tables with string/integer keys. They are copied: mutating a received table cannot modify the sender's table. Metatables are not transferred. Functions, userdata, threads, cyclic tables, nonfinite numbers and unsupported keys are rejected. Server limits per argument/result tuple are 32 values, depth 16, 4,096 visited nodes and a 48 KiB transfer budget (including node overhead). Export names are 1–64 characters; a resource can register 256 names. There are at most 256 retained requests per caller, 256 pending requests per target and 4,096 requests per host. The existing 1,024-task, instruction and VM-memory limits still apply. A pending server call times out after **30 seconds** of host monotonic time. Timeout, caller shutdown or caller reload cancels the target's scheduled task. This stops future continuation; it cannot undo writes already performed or cancel an external database/HTTP operation already submitted. Design retries to be safe. Requests bind both VM generations. A target stop or successful reload rejects pending calls to its old VM with `export_resource_stopped`. A failed transactional reload leaves the old service available: candidate exports are never published. New calls resolve the new provider after a successful reload. Stopping a declared dependency also stops its dependents under the existing lifecycle rules. Keep the Promise until you have consumed the result. Once it is collected, the request is released; a still-pending target task is cancelled. Retaining every completed Promise indefinitely eventually reaches the caller request quota. Common refusal reasons: | Reason | Meaning | | --- | --- | | `resource_preparing`, `resource_stopping` | Calling from a VM that is not active. | | `invalid_export_name`, `export_function_required` | Invalid registration/call shape. | | `export_resource_unavailable`, `export_not_found` | No active provider or no matching export. | | `export_arguments_not_serializable`, `export_results_not_serializable` | Unsupported value or transfer limit. | | `export_request_limit`, `export_target_busy`, `export_registration_limit` | A request, task or registration quota was reached. | | `await_requires_scheduler_coroutine` | A pending Promise was awaited outside a managed task. | | `export_resource_stopped`, `export_timeout`, `promise_cancelled` | The request's lifetime ended. | | `export_target_stopped` | Synchronous: the provider is stopping or not yet active. | | `export_recursion_limit` | Synchronous: the chain already has 8 frames. | | `export_yielded` | Synchronous: the callee tried to `Wait` or `await`. | | `export_budget_exhausted` | Synchronous: the callee burned its own instruction budget. | | `export_raised` | Synchronous: the callee raised; its message follows the reason. | Lua handler errors reject the Promise with the bounded Lua error text, and raise in the caller with the same text on the synchronous path. An export is not automatically registered as a network event, and an export failure alone does not stop otherwise healthy resources. A synchronous chain pins every VM it passes through for the length of the call. `Stop` and `Reload` refuse a resource that is on such a chain, with `stop refused: a synchronous export call is still on the stack` in the log, rather than disposing a Lua state that is still executing. Nothing in the current host can reach `Stop` from inside a VM callback, so this is a guard rather than a behaviour you should ever see. See the [server API reference](server-api.md#cross-resource-exports), [official client resource exports](resource-exports.md), and [gamemode architecture](gamemode-kernel.md). --- Source: https://open2077.net/docs/resource-exports # Official resource exports Use official resource exports for shared services such as interfaces, lifecycle management and gameplay helpers. These exports are separate from runtime-native methods such as `Open77.vehicles.get`. The package catalogue on this page lists **client exports**. Server resources can also publish and call their own exports using the same asynchronous surface; see [Cross-resource server exports](server-exports.md). The two registries are separate. Server-authoritative mutation remains in server scripts through the [server Lua API](server-api.md), net events, or a package's documented server interface. ## Calling an export There are two forms, and the difference is not style: one waits, the other does not. ### Synchronous — `exports.:(...)` The FiveM spelling works, on the client and on the server, and it returns the export's values directly: ```lua local fuel = exports.open77_fuel:level(vehicleId) local ok, reason = exports['open77-garage']:store(vehicleId) ``` A resource name containing a hyphen only works through the bracket form, exactly as in FiveM. `exports.res.name(...)` (a dot instead of a colon) works too; the proxy recognises the `self` the colon inserts and drops it. The call runs **inline, on this thread**: every resource VM on the client runs on the game thread and every server VM runs on the host scheduler thread, so the callee's function body executes before the call expression returns. Nothing is queued, nothing yields, no tick boundary passes. That is what makes it usable from a getter — and what makes the following true: - **The callee must not yield.** `Wait`, `promise:await()` and anything built on them fail the call with `export_yielded`. There is no scheduler underneath a synchronous callee to yield to. Use the asynchronous form for slow work. - **Failure raises.** This is the one place Open77 does not return `nil, reason`. FiveM's exports raise, ported resources are written for that, and a raise is also the only way to keep "the export returned nil" distinguishable from "the call failed". Wrap it in `pcall` when you want to handle failure yourself. - **Recursion is capped at 8 frames.** A → B → A is fine and works; the ninth nested frame fails with `export_recursion_limit`. - **A resource may call its own export.** It is an ordinary nested call, with the same copy of arguments and results as any other, and it counts toward the cap. `Open77.exports.callSync(resource, name, ...)` is the same path without the sugar, including the raise, and is what you want when the export name is in a variable. ### Asynchronous — `Open77.exports.call(...)` Unchanged, and still the right tool whenever the callee may be slow, may wait on the network or a database, or may not exist yet: ```lua local promise, reason = Open77.exports.call("open77_notifications", "show", { type = "success", title = "Garage", message = "Vehicle stored." }) if not promise then error(reason) end local result = promise:await() if not result.ok then print(result.error) end ``` It returns `nil, reason` instead of raising, it runs the callee on the callee's own scheduler where it is free to `Wait`, and `:await()` needs a managed task (`CreateThread`, a handler, a command). Every export in the catalogue below is callable both ways. ### What crosses, and what does not Both forms copy. Arguments and results are transferred by value through the same serialiser: nil, booleans, finite numbers, 64-bit integers, binary strings and nested plain tables with string or integer keys. A table you hand to another resource is **not** the table it receives, so a callee mutating it cannot touch yours, and metatables, functions, userdata and coroutines never travel. Nothing about running inline weakens that: the callee gets a fresh coroutine in its own Lua state, with its own globals, its own instruction budget and its own permissions. Synchronous and asynchronous calls differ only in *when* the callee runs, never in what it can reach. `GetInvokingResource()` and `GetInvokingResourceGeneration()` name the immediate caller for the duration of the call and are restored afterwards, including when the callee raises. ### Refusal reasons | Reason | Meaning | | --- | --- | | `export_resource_unavailable` | No resource of that name is running. | | `export_target_stopped` | It exists but is stopping, preparing, or not running. | | `export_not_found` | It is running but publishes no export of that name. | | `export_recursion_limit` | The chain already has 8 synchronous frames. | | `export_yielded` | The callee tried to `Wait` or `await`. | | `export_budget_exhausted` | The callee burned its own instruction or time budget. | | `export_raised` (server) / `... raised: ` (client) | The callee raised; the original message is appended. | | `export_arguments_not_serializable`, `export_result_not_serializable` | A value could not be copied. | | `resource_preparing` | Called at file scope while this VM was still loading. | A callee that exhausts its budget fails **that call**, with its own counter reset for the next one; the caller is not killed for it, and on the client the wall time the callee spent is credited back to the caller's own deadline. Handles returned by UI packages are resource-owned. Another resource cannot update or dismiss them, and they are cleaned automatically when the owning generation stops or reloads. ## Export catalogue ### `open77_contextmenu` Generic ALT/click framework, no built-in gameplay actions. Client-only exports: | Exports | Purpose | |---|---| | `register`, `registerMany` | Register one definition or an atomic batch. | | `registerPlayers`, `registerVehicles`, `registerNpcs`, `registerProps`, `registerDoors`, `registerWorld` | Typed registration helpers; one definition or a batch. | | `registerSelf`, `registerSky` | Visible F7 self / explicit empty-space direction actions. | | `registerModels`, `registerEntities` | Exact record or canonical/local ID filters plus definitions. | | `update`, `get`, `setEnabled`, `list` | Update (new token), inspect, enable/disable or list owned actions. | | `unregister`, `unregisterMany`, `clear` | Remove only caller-owned actions. | | `isOpen`, `isReady`, `getVersion`, `getContext`, `getTarget` | Framework status and current selection snapshot. | | `setTargetingEnabled`, `isTargetingEnabled`, `close` | Resource-scoped disable claims and input lifecycle. | Actions belong to their invoking resource/generation; callbacks are provider export names, not closures. Self/sky filters are opt-in, and sky coordinates are a ray endpoint, not a physical impact. Provider stop cleans up actions/claims. See [Context menu](context-menu.md) for contracts, installation and server security. ### `open77_animations` | Export | Signature | Result | |---|---|---| | `list` | `list(query?)` | Matching profile definitions. | | `get` | `get(profileId)` | Profile or nil. | | `request` | `request(profileId, options?)` | Server-accepted local playback, or nil/error. | | `sequence` | `sequence(steps, options?)` | Server-accepted local sequence, or nil/error. | | `cancel` | `cancel(playbackId?)` | Queue self-cancellation; omitted ID also abandons pending requests. | | `state` | `state(playerId?)` | Active canonical state; local player by default. | The preferred client facade is `Open77.animations.(...):await()` with the same arguments/results. Preserve the initial `nil, error` dispatch check before awaiting. See [RP animations](rp-animations.md) for examples, permissions, lifecycle and the precise native playback validation limits. ### `open77_appearance` | Export | Signature | Result | |---|---|---| | `open` | `open(mode?)` | Requests the server-authorized appearance workflow. | | `capture` | `capture()` | Current native appearance snapshot. | | `isOpen` | `isOpen()` | Whether the appearance UI is active. | | `revision` | `revision()` | Last canonical appearance revision. | | `characterKey` | `characterKey()` | Durable character key associated with the synchronized appearance. | See the package README at [`resources/system/open77_appearance/README.md`](https://github.com/Open2077/open77-resources/blob/main/system/open77_appearance/README.md) for the transaction lifecycle. ### `open77_equipment` There is no `open77_clothing` package. Client-side clothing lives in [`resources/open77_equipment`](https://github.com/Open2077/open77-resources/tree/main/standalone/open77_equipment), and the wardrobe it can hide lives in [`resources/open77_wardrobe`](https://github.com/Open2077/open77-resources/tree/main/standalone/open77_wardrobe); `Open77.clothing` is a **server** namespace whose requests this package answers. | Export | Signature | Result | |---|---|---| | `slots` | `slots()` | The nine canonical slots and their REDengine attachment slots. | | `records` | `records(options?)` | Filtered, bounded catalogue query: `slot`, `family`, `restricted`, `limit`. | | `info` | `info(record)` | Metadata for one exact catalogue record. | | `registry` | `registry()` | What is currently in every slot. | | `equip` | `equip(record, slot?, options?)` | Validates and equips one local record. | | `unequip` | `unequip(slot)` | Empties one slot without deleting the inventory item. | | `apply` | `apply(slots, options?)` | Applies a whole slot-to-record table in one transaction. | | `beginPreview` | `beginPreview()` | Claims temporary native mutation for the fitting room. | | `endPreview` | `endPreview()` | Releases the claim and re-applies the real state. | The two preview exports are **not general-purpose**: they refuse any caller other than `open77_wardrobe_ui`, checked with `GetInvokingResource()`. The fitting room owns temporary native mutations; it never owns server intent. The dedicated runtime separately exposes asynchronous server methods under `Open77.clothing`; they are not client package exports. They target a player and complete through `open77:clothing:completed`, which this package answers. See [Complete server Lua API](server-api.md#player-clothing). ### `open77_wardrobe` | Export | Signature | Result | |---|---|---| | `beginPreview` | `beginPreview()` | Claims the wardrobe preview for the fitting room. | | `endPreview` | `endPreview()` | Releases the claim and restores the replicated wardrobe. | Same ownership rule as `open77_equipment`: both exports refuse a caller other than `open77_wardrobe_ui`. Everything else this package does — projecting other players' outfits onto their puppets through `Open77.puppets.setWardrobe` — happens on events, not on exports. ### `open77_weapons` | Export | Signature | Result | |---|---|---| | `slots` | `slots()` | `{ 1, 2, 3 }`. | | `assign` | `assign(record, slot, options?)` | Native asynchronous request ID. | | `setActive` / `activate` | `setActive(slot)` or `setActive(record, options?)` | Native asynchronous request ID. | | `remove` / `unequip` | `remove(slot)` | Native asynchronous request ID. | | `holster` | `holster()` | Native asynchronous request ID. | | `setAmmo` | `setAmmo(slot, amounts)` | Sets bounded reserve/magazine amounts through the native asynchronous request path. | | `snapshot` / `all` | `snapshot()` | Native asynchronous request ID. | The package also owns the authenticated target-client relay used by server `Open77.weapons.*` calls. Completion, slot-state fields, permissions, errors, and authority rules are documented in [Weapon Lua API](weapons-api.md). ### `open77_perspective` | Export | Signature | Result | |---|---|---| | `get` | `get()` | Current preferred perspective mode. | | `set` | `set(mode)` | Requests `first` or `third` person through the ownership arbiter. | | `toggle` | `toggle()` | Toggles the preferred mode. | | `state` | `state()` | Current preference, effective mode, ownership and availability. | | `key` | `key()` | Current local toggle key. | | `setKey` | `setKey(key)` | Validates and persists the local toggle key. | See [Perspective](perspective.md). ### `open77_vehiclepicker` | Export | Signature | Result | |---|---|---| | `open` | `open(options?)` | Opens the picker for a declared roster/list. | | `close` | `close()` | Closes it and releases UI focus. | | `selection` | `selection(listId)` | Current persisted key for a known list. | | `roster` | `roster(listId)` | Read-only roster data for a custom gamemode UI. | | `isOpen` | `isOpen()` | Whether the picker currently owns its surface. | ### `open77_chat` | Export | Signature | Result | |---|---|---| | `addMessage` | `addMessage(message)` | Adds one structured message to the local chat UI. | | `clear` | `clear()` | Clears visible messages. | | `addSuggestion` | `addSuggestion(command, help, parameters?)` | Adds or replaces slash-command completion metadata. | | `removeSuggestion` | `removeSuggestion(command)` | Removes one completion entry. | | `setEnabled` | `setEnabled(enabled)` | Enables/disables chat for the calling resource context. | | `isEnabled` | `isEnabled()` | Returns the current enable state. | Message and suggestion schemas are documented in [Chat](chat.md). ### `open77_death` | Export | Signature | Result | |---|---|---| | `isDead` | `isDead(playerId?)` | Canonical death state for the local or selected player. | | `getState` | `getState(playerId?)` | Canonical life-state snapshot. | | `getLocalDeathContext` | `getLocalDeathContext()` | Local death context, with the last package snapshot as fallback. | | `all` | `all()` | All currently known player life states. | ### `open77_effects` | Export | Signature | Result | |---|---|---| | `playVfx` | `playVfx(effect, options?)` | Starts a world VFX owned by the caller. | | `playEntityVfx` | `playEntityVfx(effect, options?)` | Starts an entity-attached VFX. | | `playSfx` | `playSfx(event, options?)` | Starts an SFX event, optionally spatialized or attached. | | `stop` | `stop(handle)` | Stops a caller-owned effect handle. | | `catalog` | `catalog()` | Returns the runtime effect catalogue. | | `looping` | `looping()` | Server-owned looping effects this client is currently projecting. | See [Visual and audio effects](effects.md) and [Game data reference](data-reference.md). ### `open77_props` | Export | Signature | Result | |---|---|---| | `get` | `get(id)` | One projected server prop record, by its server ID. | | `all` | `all()` | Every server prop this client is currently projecting, ordered by ID. | Read-only by design. Creating or mutating a replicated prop exists only on the dedicated-server `Open77.props` API — a client cannot mint a prop anybody else can see. There is deliberately no `handle` export: the projection layer is keyed by the server's own ID, so a second identity could only ever get out of step with it. See [Server-owned world props](props.md). ### `open77_devices` **Server exports only.** The client half owns the device-interaction claim for the whole server-declared policy and exposes nothing a caller would reach; the client API it uses is `Open77.world.setDeviceInteractionEnabled`, documented in [vanilla device prompts](device-interactions.md). | Export | Signature | Result | |---|---|---| | `disable` | `disable(target, options?)` | Turns the vanilla prompt off for that engine entity id or device class on every client. `options.bucket` scopes it to one routing bucket. `true`, or `false, reason`. | | `enable` | `enable(target, options?)` | Declares an explicit allow. Not the same as `clear`: this is still a policy this resource holds. | | `clear` | `clear(target?)` | Withdraws one declaration, or all of them when `target` is omitted. | | `list` | `list()` | What this resource currently has declared. | Late joins and routing-bucket changes are answered; a declaring resource that stops takes its policies with it. The package also re-publishes every vanilla device use host-wide as `open77_devices:used(playerId, engineEntity, className, choice, allowed)` -- a rate-limited **claim** by that client, never authority. ### `open77_elevators` | Export | Signature | Result | |---|---|---| | `get` | `get(id)` | One streamed authoritative elevator snapshot. | | `all` | `all()` | All streamed elevator snapshots. | | `requestFloor` | `requestFloor(id, floor)` | Requests a `goto` action. | | `requestCall` | `requestCall(id, floor)` | Requests a `call` action. | See [Elevators](elevators.md). Elevator IDs are opaque even though this compatibility package currently normalizes them before calling the native API. ### `open77_fuel` The canonical [state-bag sample](state-bags.md#the-fuel-sample-open77_fuel): fuel is a server-owned `fuel` key on every vehicle's bag, burned from the replicated speed and engine state, with the engine cut at zero. | Export | Signature | Result | |---|---|---| | `level` | `level(vehicleId)` | The replicated litres, read from the bag mirror; `nil, reason` for an unknown or unstreamed car. | | `current` | `current()` | The litres of the car the local player is sitting in, or `nil, "not_in_vehicle"`. | The client also re-emits every bag change as `open77:fuel:changed(vehicleId, litres, previous)`. There is no client `set`: the client never writes a bag. **Server exports**, on the same package, for scripts porting `GetVehicleFuelLevel` / `SetVehicleFuelLevel`: `level(vehicleId)`, `set(vehicleId, litres)`, `refuel(vehicleId, litres?)` (to the brim when the amount is omitted), `capacity()` and `configure({ capacity, litresPerHundredKm, idleLitresPerMinute, multiplier })`. Every write is clamped into the tank and answers `litres | nil, reason`; the host-wide `open77:fuel:empty(vehicleId)` fires once per empty. ### `open77_groundcircle` | Export | Signature | Result | |---|---|---| | `create` | `create(definition)` | Creates a caller-owned HUD ground circle and returns its handle. | | `remove` | `remove(handle)` | Removes one caller-owned circle. | | `clear` | `clear()` | Removes every circle owned by the caller; answers with `removed`. | | `list` | `list()` | Snapshots of every circle owned by the caller. | A screen overlay rather than a world entity: the circle is sampled in world space, projected point by point with `Open77.camera.project` and stroked as an SVG polyline on a transparent HUD surface, so it depends on no depot asset and cannot report itself as rendered while drawing nothing. The trade-offs are that it is not occluded by geometry, receives no light, exists only for the local player, and is sampled on the Lua tick rather than per frame, so it trails a fast camera whip. `create` deliberately reads the same field names [`open77_worldui`](worldui.md) reads --- `id`, `position`, `radius`, `maxDistance`, `style`, `groundOffset`, `color` --- and *ignores* the prompt half of that definition rather than rejecting it, so one definition table can be handed to either resource. It also accepts `width` (0.5--8.0), `opacity` (0.05--1.0), `fill` (0.0--0.6) and `visible`. `radius` is clamped to 0.1--50.0 and `maxDistance` to 1.0--500.0, matching `Open77.markers`. The limit is 32 circles across every resource combined (`circle_limit`). It is a diagnostic and a fallback for a native marker that is not producing pixels, not a replacement for one. For a ground ring that must be frame-tight, use `Open77.anchors` with `render = "ring"`, which is projected natively on the frame that draws it. ### `open77_interactions` | Export | Signature | Result | |---|---|---| | `create` | `create(definition)` | Creates a caller-owned contextual interaction and returns its handle. | | `update` | `update(handle, patch)` | Applies a partial update. | | `remove` | `remove(handle)` | Removes one caller-owned interaction. | | `setVisible` | `setVisible(handle, visible)` | Convenience visibility update. | | `get` | `get(handle)` | Snapshot of one caller-owned interaction. | | `all` | `all()` | Snapshots of every interaction owned by the caller. | | `clear` | `clear()` | Removes all caller-owned interactions. | | `setEnabled` | `setEnabled(enabled)` | Enables/disables rendering and input for the caller. | | `isEnabled` | `isEnabled()` | Returns the caller enable state. | Targeting — standing rules that materialise interactions on whatever the world currently holds, rather than one prompt bolted to one place: | Export | Signature | Result | |---|---|---| | `addTarget` | `addTarget(definition)` | Registers a caller-owned target rule and returns its handle. | | `removeTarget` | `removeTarget(handle)` | Removes one caller-owned target and every prompt it materialised. | | `clearTargets` | `clearTargets()` | Removes every client-declared target owned by the caller. | | `getTarget` | `getTarget(handle)` | Snapshot of one caller-owned target, including its live match count. | | `allTargets` | `allTargets()` | Snapshots of every target owned by the caller. | | `addModel` | `addModel(models, options)` | `addTarget` with `kind = "model"`. | | `addClass` | `addClass(classes, options)` | `addTarget` with `kind = "class"`. | | `addGlobalVehicle` | `addGlobalVehicle(options)` | `addTarget` with `kind = "globalVehicle"`. | | `addGlobalPlayer` | `addGlobalPlayer(options)` | `addTarget` with `kind = "globalPlayer"`. | | `addGlobalPed` | `addGlobalPed(options)` | `addTarget` with `kind = "globalNpc"`. `addGlobalNpc` is the same export. | | `addZone` | `addZone(options)` | `addTarget` with `kind = "zone"`. | | `addBoxZone` | `addBoxZone(options)` | `addZone` with `position` + `size` (+ `heading`) folded into a box shape. | | `addSphereZone` | `addSphereZone(options)` | `addZone` with `position` + `radius` folded into a sphere shape. | **Server exports** (a declaration made once, applied by every client — see [Cross-resource server exports](server-exports.md)): | Export | Signature | Result | |---|---|---| | `define` | `define(target \| { target, ... })` | Publishes this resource's targets to every client. `true`, or `false, reason`. | | `undefine` | `undefine(id?)` | Withdraws one target, or all of them when `id` is omitted. | | `clear` | `clear()` | Withdraws every target this resource declared. | | `list` | `list()` | The ids this resource currently has declared. | Definitions, marker types, choices, distances, entity attachment, target kinds, `canInteract`, group gates, and responses are documented in [Contextual interactions](interactions.md). ### `open77_loot` | Export | Signature | Result | |---|---|---| | `get` | `get(id)` | One streamed canonical loot drop. | | `all` | `all()` | Copy of all streamed drops. | | `requestPickup` | `requestPickup(id)` | Submits a bounded pickup request to the server. | See [Loot](loot.md). Clients cannot create or award authoritative loot. ### `open77_markers` | Export | Signature | Result | |---|---|---| | `create` | `create(definition)` | Creates a native marker and returns its ID. | | `update` | `update(id, patch)` | Updates mutable marker fields. | | `remove` | `remove(id)` | Removes one marker. | | `clear` | `clear()` | Removes resource-owned markers. | | `list` | `list()` | Lists current resource-owned markers. | The lower-level method schema is in the generated `Open77.markers.*` reference. ### `open77_nameplates` | Export | Signature | Result | |---|---|---| | `setEnabled` | `setEnabled(enabled)` | Enables/disables caller-owned overrides. | | `isEnabled` | `isEnabled()` | Current enable state. | | `set` | `set(playerId, options)` | Sets a nameplate override for one player. | | `remove` | `remove(playerId)` | Removes one override. | | `clear` | `clear()` | Removes every caller-owned override. | **Invisible players never get a plate, whatever the override says.** When the server hides a player with [`Open77.players.setVisible(id, false)`](server-api.md#player-visibility), his nameplate is withheld along with his body, on every surface that reads the roster -- the native overlay, a streamed page, and `Open77.nameplates` snapshots alike. This is not a style choice a resource can override: the plate is projected from the body's world position, so leaving it up would draw the exact location that hiding the body exists to conceal. `options.visible` still governs everything else. The plate returns on its own when the player is made visible again, or when any of the automatic releases fires (death, respawn, bucket change, disconnect, reconnect, or the hiding resource stopping). Client code never has to reconcile it: the roster is filtered from the replicated life flag, which is the single source of truth -- **2.31 exposes no readable hidden flag on the engine side, so no client can be asked whether a body is really hidden right now.** ### `open77_notifications` | Export | Signature | Result | |---|---|---| | `show` | `show(definition)` | Displays a caller-owned notification and returns its handle. | | `update` | `update(handle, patch)` | Updates a visible or queued notification. | | `dismiss` | `dismiss(handle)` | Dismisses one caller-owned notification. | | `clear` | `clear()` | Dismisses every caller-owned notification. | | `list` | `list()` | Snapshots of caller-owned notifications. | | `setEnabled` | `setEnabled(enabled)` | Enables/disables notifications for the caller. Disabling also clears them. | | `isEnabled` | `isEnabled()` | Returns the caller enable state. | See [Notifications](notifications.md) for types, positions, duration, progress, actions, replacement, and the server-to-client envelope. ### `open77_uikit` Every dialog here **waits for a human**, so it must be called through `Open77.exports.call(...)`; the synchronous `exports.open77_uikit:alert(...)` spelling fails a waiting callee with `export_yielded`. A dispatch failure is `nil, reason`; a widget that actually ran resolves with `{ ok, outcome, value }`, where `outcome` is `"ok"`, `"cancelled"` or `"timeout"`. **A cancel is an outcome, never a rejection.** | Export | Signature | Result | |---|---|---| | `progress` | `progress(definition)` | **Waits.** Runs a timed bar, blocking the actions named in `disable` through `Open77.input`. | | `progressActive` | `progressActive()` | `boolean, owner`. | | `cancelProgress` | `cancelProgress()` | Ends the caller's own bar early. | | `textUI` | `textUI(definition or text)` | Shows or replaces the caller's persistent hint. | | `hideTextUI` | `hideTextUI()` | Removes it. Idempotent. | | `textUIState` | `textUIState()` | `{ open, text, position, total }`. | | `alert` | `alert(definition)` | **Waits.** A confirm/cancel panel; `value` is `"confirm"`. | | `input` | `input(definition)` | **Waits.** A form; `value` carries the fields keyed by id and indexed by position. | | `context` | `context(definition)` | Registers a browse menu under `definition.id`, owner-scoped. | | `showContext` | `showContext(id, options?)` | **Waits.** Opens it, with in-dialog navigation through an option's `menu` field. | | `hideContext` | `hideContext()` | Closes the caller's open dialog. | | `menu` | `menu(definition)` | Registers a keyboard menu with checkbox and side-scroll rows. | | `showMenu` | `showMenu(id, options?)` | **Waits.** Opens it; the answer carries `values` even on a close. | | `hideMenu` | `hideMenu()` | Closes the caller's open dialog. | | `radial` | `radial(definition)` | Registers an eight-sector wheel. | | `showRadial` | `showRadial(id, options?)` | **Waits.** Opens it. | | `hideRadial` | `hideRadial()` | Closes the caller's open dialog. | | `drawText3D` | `drawText3D(definition)` | A string floating over a world point or entity, drawn natively through the anchor facade. Returns a handle; 8 per owner, 24 in all. | | `updateText3D` | `updateText3D(handle, patch)` | Changes the text, colours, scale, distance band or position of a caller-owned text. | | `clearText3D` | `clearText3D(handle?)` | Removes one text, or every text the caller drew (`true, count`). Idempotent. | | `listText3D` | `listText3D()` | The caller's texts with the host's last projection: `distance`, `onScreen`, `inRange`, `expiresIn`. | | `showCinematicBars` | `showCinematicBars(enabled, options?)` | Claims or releases the letterbox: two bars plus the vanilla HUD hidden under the kit's own loan. | | `setCinematic` | `setCinematic(enabled, options?)` | The same claim under the parity ledger's name. | | `cinematicState` | `cinematicState()` | `{ active, hudHidden, mine, holders, heightPct, color }`. | | `close` | `close()` | Cancels every widget the caller holds; returns `true, count`. | | `state` | `state()` | Diagnostic snapshot: surface readiness, focus, held blocks, live widgets. | One focus-taking dialog exists across the whole session: a second request is refused with `dialog_active` rather than queued. The kit spends exactly **one** WebUI surface for all of it. The server twins -- `progress`, `alert`, `input`, `context`, `menu`, `radial`, `textUI`, `hideTextUI`, `drawText3D`, `updateText3D`, `clearText3D`, `showCinematicBars` / `setCinematic` and `close`, each taking a `playerId` first -- are server exports, not client ones, and live on the same resource. See [The UI kit](ui-kit.md) for definitions, the focus release matrix, the styling tokens a resource may override, failure reasons and a worked job step. ### `open77_rp_basics` **Server exports only.** The role-play kit publishes no client exports: its client half owns a control-block claim and nothing a caller would want to reach. | Export | Signature | Result | |---|---|---| | `cuff` | `cuff(officerId, targetId, options?)` | Freezes the target, blocks his controls and plays `handsup`. `true`, or `false, reason`. | | `escort` | `escort(officerId, targetId, options?)` | Lets him walk with everything but movement taken away, tethered to the officer. `true`, or `false, reason`. | | `search` | `search(officerId, targetId, options?)` | Starts a timed frisk; returns a ticket string, or `false, reason`. The report arrives as `open77_rp_basics:searched`. | | `carry` | `carry(officerId, targetId)` | Always `false, "carry_unsupported"` -- nothing on 2.31 can parent one player to another. Present so a port gets a named refusal instead of a silent no-op. | | `release` | `release(targetId, byPlayerId, reason?)` | Releases what this kit holds. The holding officer, an `rp.admin`, or the console (`0`). `true`, or `false, reason`. | | `state` | `state(targetId)` | The standing hold on one player, or nil. | | `held` | `held()` | Every standing hold. | | `capabilities` | `capabilities()` | The verb catalogue, with `supported` and a reason for the one that is not. Read this instead of hard-coding what a build can do. | Authority is the ACL (`rp.cuff`, `rp.escort`, `rp.search`, `rp.admin`), not the caller: an export carries no player identity. See [the role-play kit](rp-kit.md) for the gate, the leash, and the twelve ways a hold ends. ### `open77_sound` | Export | Signature | Result | |---|---|---| | `play` | `play(id, file, options?)` | Plays a file the **calling** resource ships. Returns the id, or `nil, reason`. | | `stop` | `stop(id)` | Stops one caller-owned sound. | | `stopAll` | `stopAll()` | Stops every caller-owned sound; returns how many. | | `setVolume` | `setVolume(id, volume)` | 0..1, ramped so it does not click. | | `setPosition` | `setPosition(id, position)` | Moves a spatial sound; `sound_not_spatial` on a 2D one. | | `isPlaying` | `isPlaying(id)` | Whether a caller-owned sound is still playing. | | `list` | `list()` | `{ id, file, loop, volume, spatial }` for caller-owned sounds. | | `preload` | `preload(file)` | Ships and decodes a clip ahead of its first play. | `file` must be declared in the **calling** resource's `files` manifest entry — a resource can only play audio it ships itself, and that is enforced by the host, not by convention. `options`: `position`, `entity`, `volume`, `loop`, `pitch`, `offset`, `maxDistance`, `refDistance`. See [Sound](sound.md) for the spatial model, the caps, the server twins (`Open77.sound.*`), and the plain list of what this system is **not** — it is not Wwise, and `Open77.sfx.*` remains the way to play the game's own audio events. ### `open77_vehicles` | Export | Signature | Result | |---|---|---| | `get` | `get(id)` | One streamed canonical vehicle snapshot. | | `all` | `all()` | All streamed canonical vehicle snapshots. | | `getPlayerSeat` | `getPlayerSeat(playerId?)` | One replicated seat assignment; omitted ID selects the local player. | | `isPlayerExitLocked` | `isPlayerExitLocked(playerId?)` | Replicated exit-lock boolean, or `nil` without an assignment. | See [Vehicles](vehicles.md) for server mutation and native presentation methods. ### `open-voice` | Export | Signature | Result | |---|---|---| | `getState` | `getState()` | Current canonical mode, distance, cycle key and HUD visibility. | | `getModes` | `getModes()` | Server-published reach presets. | | `requestCycle` | `requestCycle()` | Requests the next server-owned mode; no distance is sent by the client. | | `setHudVisible` | `setHudVisible(visible)` | Locally shows or hides the passive voice HUD. | See [Integrated voice chat](voice.md). Mode changes remain authoritative on the server; these client exports cannot submit arbitrary reach. ### `open77_voice` | Export | Signature | Result | |---|---|---| | `status` | `status()` | Native capture/render/device and packet snapshot. | | `getPushToTalkKey` | `getPushToTalkKey()` | Current local PTT binding. | | `setPushToTalkKey` | `setPushToTalkKey(key)` | Applies a supported PTT binding without restarting. | | `devices` | `devices(flow?)` | Input/output endpoint catalogue. | | `getProximityDistance` | `getProximityDistance()` | Canonical reach plus the full native status snapshot. | | `setProximityDistance` | `setProximityDistance(distance)` | Request a custom reach; server policy clamps it. | | `getProximityMode` | `getProximityMode()` | Current `whisper`, `normal`, `shout`, or `custom` mode and distance. | | `setProximityMode` | `setProximityMode(name, notify?)` | Request a configured reach preset. | | `cycleProximityMode` | `cycleProximityMode(notify?)` | Select the next configured preset. | | `setPlayerVolume` | `setPlayerVolume(playerId, gain)` | Local per-talker gain. | | `setPlayerBlocked` | `setPlayerBlocked(playerId, blocked)` | Local per-talker block. | | `setChannelVolume` | `setChannelVolume(channelId, gain)` | Local current/future channel-route gain. | Server scripts in the same package additionally publish helpers for radio/phone channel creation, membership, authoritative mute/deaf, and proximity. Those helpers execute only in the dedicated runtime; use [Integrated voice chat](voice.md) for both surfaces and the server-authority rules. ### `open77_weather` | Export | Signature | Result | |---|---|---| | `isReady` | `isReady()` | Whether the first authoritative environment snapshot arrived. | | `requestSync` | `requestSync()` | Requests an immediate resynchronization. | | `getState` | `getState()` | Projected server time and canonical weather state. | See [Weather](weather.md). This package intentionally exposes no client mutation command. ### `open77_worldui` | Export | Signature | Result | |---|---|---| | `create` | `create(definition)` | Creates a caller-owned POI (marker plus optional prompt) and returns its handle. | | `remove` | `remove(handle)` | Removes one caller-owned POI, both halves. | | `list` | `list()` | Snapshots of every POI owned by the caller. | | `dump` | `dump()` | Diagnostic: logs and returns the native marker registry, including `rendered`. | Mode-agnostic; both official consumers ([`resources/gamemodes/pursuit`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/pursuit), [`resources/gamemodes/race`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/race)) call it unmodified. See [World-anchored POIs](worldui.md). ### `open77_zones` | Export | Signature | Result | |---|---|---| | `create` | `create(definition)` | Creates a caller-owned proximity zone with enter/exit hysteresis. | | `remove` | `remove(handle)` | Removes one caller-owned zone. | | `contains` | `contains(handle)` | The service's last-polled containment verdict for one caller-owned zone. | Client-only, local presentation signal -- never proof of position. See [Proximity zones](zones.md) for the polling model and the server-side re-validation every caller must apply. ### `open77_example` | Export | Signature | Result | |---|---|---| | `hello` | `hello(name?)` | Minimal export example used by the starter package. | This export is instructional and should not be used as a production dependency. ### `open77_equipment` These client exports forward to the local `Open77.equipment` adapter. They do not submit a durable server clothing transaction; authoritative presentation can subsequently replace a local edit. See [Equipment and wardrobe](../docs/equipment.md). | Export | Signature | Result | |---|---|---| | `slots` | `slots()` | Supported slot names and attachment slots. | | `records` | `records(options?)` | Record metadata; optional slot, family, restricted and limit filters. | | `info` | `info(record)` | Metadata for one record, or `nil, reason`. | | `registry` | `registry()` | Worn slot values: record strings or `false` for empty. | | `equip` | `equip(record, slot?, options?)` | Native mutation accepted, or `nil, reason`; not visual completion. | | `unequip` | `unequip(slot)` | Removes one local worn slot without deleting inventory. | | `apply` | `apply(slots, options?)` | Sequential slot mutation; can partially apply before an error. | | `beginPreview` | `beginPreview()` | `true` only for `open77_wardrobe_ui` when presentation is ready and no preview is held. | | `endPreview` | `endPreview()` | Releases the calling preview owner and reapplies authoritative presentation. | Preview ownership is checked with `GetInvokingResource()`. Other callers receive `preview_owner_denied`; unavailable initial presentation returns `presentation_not_ready`. Owner resource stop also ends its preview. Preview acceptance does not prove that native clothing has finished rendering. ### `open77_wardrobe` | Export | Signature | Result | |---|---|---| | `beginPreview` | `beginPreview()` | `true` for `open77_wardrobe_ui` only when authoritative wardrobe presentation is ready and no preview is held. | | `endPreview` | `endPreview()` | Releases the calling preview owner and restores pending or current authoritative wardrobe state. | The same `preview_owner_denied` and `presentation_not_ready` guards apply. Stopping the preview owner also restores authoritative state. These coordination exports do not create a persistent outfit or expose arbitrary preview ownership. ## Reference coverage This catalogue covers official client-resource exports. Additional system-resource exports are documented in their feature guides. Use explicit export names so resource tooling can discover them. --- Source: https://open2077.net/docs/fivem-compatibility # FiveM compatibility aliases Port FiveM resources using Open77's compatibility aliases for threads, timers, events, commands, key mappings, exports and promises. This guide lists supported aliases and the APIs needed where engine behavior differs. Everything here is an **alias or an addition**. No function that existed before changed meaning, and nothing widens the sandbox: `io`, `os`, `debug`, `package` and `load` are still absent, and `LoadResourceFile` cannot leave the calling resource. The second half of the page is the porting notes that are *not* aliases — where a FiveM idiom maps onto something shaped differently here: the [damage feedback events](#damage-feedback-events-client), [keeping an NPC on task](#keeping-an-npc-on-task), [draw calls](#porting-fivem-draw-calls), [manifest keys](#manifest-keys), and the streaming loops that have [nothing to wait on](#what-is-missing-and-why). Three places where Open77 deliberately answers differently from FiveM are called out below in their own sections. Read those before porting: they are silent traps otherwise. ## Availability at a glance | Name | Client | Server | Maps to | |---|---|---|---| | `Citizen.CreateThread` / `Citizen.Wait` | yes | yes | the existing `CreateThread` / `Wait` — the same function value | | `Citizen.SetTimeout` / `Citizen.ClearTimeout` | yes | yes | the existing `SetTimeout` / `ClearTimeout` | | `Citizen.SetTick` / `Citizen.ClearTick` | yes | yes | `SetTick` / `ClearTick` below | | `Citizen.Trace` | yes | yes | the resource log at debug level — `DBG` on both sides. (Until A17 landed the server wrote it as `INF`; `Open77.log.debug/info/warn/error` now reach the server log as `DBG/INF/WRN/ERR`, with ANSI escapes and FiveM `^N` colour codes stripped) | | `Citizen.Await` | yes | yes | `promise:await()` | | `Citizen.CreateThreadNow` | **no** | **no** | see [What is missing](#what-is-missing-and-why) | | `SetTick(fn)` / `ClearTick(id)` | yes | yes | a scheduler task looping with an implicit `Wait(0)` | | `GetGameTimer()` | yes | yes | monotonic milliseconds | | `promise.new()` | yes | yes | the existing `Open77.Promise` type | | `IsDuplicityVersion()` | `false` | `true` | — | | `LoadResourceFile(res, path)` | yes | yes | `Open77.resource.readFile` / `Open77.io.read` | | `SaveResourceFile(res, path, data)` | no | yes | `Open77.io.write` | | `GetHashKey(str)` / `joaat(str)` | yes | yes | **TweakDBID**, not a Jenkins hash | | `DoesEntityExist(kind, id)` | yes | yes | the registry that owns that kind | | `SetEntityInvincible(kind, id, enabled)` | no | yes | `Open77.players.setGodMode` for `"player"`; every other kind is refused **by name** — see [below](#setentityinvinciblekind-id-enabled) | | `IsPlayerAceAllowed(playerId, perm)` | no | yes | `Open77.acl.isAllowed` | | `IsPrincipalAceAllowed(userId, perm)` | no | yes | the same check, addressed by account id | | `add_ace allow` | no | yes | `Open77.acl.grant(userId, permission)` against a user, `Open77.acl.definePermission(role, …)` against a group. Both need a **scoped** manifest capability; see [the ACL guide](server-acl.md#changing-the-acl-from-a-resource-at-runtime) | | `remove_ace allow` | no | yes | `Open77.acl.revoke` | | `add_principal ` | no | yes | `Open77.acl.addRole(userId, role)`; `remove_principal` is `removeRole` | | `AddExplosion(x, y, z, type, damage, audible, invisible, shake)` | no | yes | `Open77.effects.explosion(position, options)` — server-side, because the damage goes through the stats authority. `type` is a `vfx` alias rather than a native enum | | `explosionEvent` | no | yes | `onExplosion`, host-wide, carrying counts rather than names | | `StartScriptFire(x, y, z, maxChildren, isGasFire)` | no | yes | `Open77.effects.fire(position, options)` — server-side, same reason as the blast. `maxChildren` has no equivalent: a fire is one radius, not a spreading tree. `isGasFire` is a `vfx` alias (`fire.gas`) | | `RemoveScriptFire(handle)` | no | yes | `Open77.effects.removeFire(fireId)`; `Open77.effects.fires()` lists what this resource still has burning | | `GetScriptFireCoords` / `IsEntityOnFire` | no | partly | `Open77.effects.fires()` answers the first for a resource's own fires; there is no per-entity burning flag, because nothing in the engine replicates one | | `fireEvent` | no | yes | `onFire`, host-wide, with `started`/`stopped` and a count rather than names | | `ptFxEvent` | no | yes | `onParticleEffect`, host-wide, for every world-positioned server effect. Entity-bound `playOn`/`attach` raise nothing: the target owns the transform | | `GetPlayerIdentifierByType(playerId, type)` | no | yes | Bare `open77`, `userId`, `name`, `fingerprint`, `license`, `steam` or `gog` value | | `GetPlayerIdentifiers(playerId)` | no | yes | Available identifiers as `"type:value"` strings; table form: `Open77.players.identifiers` | | `RegisterCommand(name, fn, restricted?)` | yes | yes | server: ACL `command.`; client: `Open77.runtime.registerCommand` | | `ExecuteCommand(line)` | yes | yes | client: the local registry only; server: the console, see below | | `GetRegisteredCommands()` | yes | yes | `Open77.runtime.commands()` | | `GetPlayerPing(playerId)` | no | yes | `Open77.players.ping` | | `GetPlayerEndpoint(playerId)` | no | yes | `Open77.players.endpoint`, behind `players.identity.sensitive` | | `GetConvar` / `GetConvarInt` / `GetConvarBool` / `SetConvar` | no | yes | the `convars` block, then this resource's tunables | | `GetResourceMetadata(name, key)` | no | yes | `Open77.resource.metadata`; no third `index` argument | | `StartResource` / `StopResource` | no | yes | `Open77.resource.start` / `stop`, behind `resources.control` | | `GetNumResources()` / `GetResourceByFindIndex(i)` | no | yes | `Open77.resource.list()`; the find index is zero-based | | `CancelEvent()` / `WasEventCanceled()` | no | yes | the cancellable bus (`TriggerCancellableEvent`), and inside a `playerConnecting` handler the FiveM refusal exactly: `setKickReason(msg)` then `CancelEvent()` — see [Connection control](connection-control.md#setkickreason-and-cancelevent-exactly-as-in-fivem) | | `TriggerLatentClientEvent(name, target, bytesPerSecond, ...)` | no | yes | the same function under the FiveM name (`Open77.net.emitLatent`): up to 4 MiB cut into paced 40 KiB frames — [Latent client events](server-api.md#latent-chunked-client-events) | | `SetHttpHandler(fn)` | no | yes | `Open77.http.listen("/", fn)` under the FiveM name; the listener is an operator opt-in and the route lives under `//` — [Serving HTTP](server-api.md#serving-http) | | `GetPlayerTimeOnline(playerId)` / `GetPlayerLastMsg(playerId)` | no | yes | `Open77.players.sessionStats(id).sessionSeconds * 1000` and `Open77.players.get(id).ageMs` — milliseconds, the units FiveM documents | | `GetPlayerLocale(playerId)` | no | yes | `Open77.players.locale` under the FiveM name: the engine's own language setting, reported by the client (`Open77.session.locale()` there). `GetCurrentLanguage` has no counterpart — read `code` from the same table | | `SetTimeScale(scale)` | yes | per bucket | `Open77.world.setTimeScale(scale, options)` on the client — a claim per resource, eased and timed, released on stop; `Open77.world.setTimeScale(bucket, scale, options)` on the server replicates one beat to a routing bucket. Zero is refused (it freezes input with the world) — [World time](world-time.md) | | `SetGravityLevel` | partly | no | only `Open77.chute.arm(gravity, …)`, which rewrites the local player's gravity while airborne and shields the landing; there is no neutral world-gravity knob | | `FreezeEntityPosition(vehicle, true)` | yes | yes | client `Open77.vehicles.setFrozen` (local chassis physics, `vehicles.performance`); server `Open77.vehicles.setFrozen` pins the canonical pose and every client's projection follows. The control lock is the separate `setUndriveable`, and an impound wants both — [Freezing a car](vehicles.md#freezing-a-car). Players: `Open77.players.setFrozen` / `Open77.character.setFrozen` | | `GetGroundZFor_3dCoord(x, y, z)` | yes | yes | client `Open77.world.groundZ(x, y, fromZ?)` (a static-geometry ray); server `Open77.world.groundZ({ x, y }, options)` is an **observation relayed from the nearest connected client**, `nil, "no_observer"` when nobody is near — never an invented height — [World queries](world-queries.md) | | `SetPedToRagdoll(ped, ms, …)` / `ClearPedTasksImmediately(ped)` | no | yes | `Open77.players.ragdoll(id, { durationMs })` (the engine's knockdown, clamped to 3000 ms; needs the motion service, i.e. a server with a database) and `Open77.players.clearTasks(id)` — [Player freeze](player-freeze.md#putting-a-player-down-and-clearing-everything-ragdoll-and-cleartasks) | | fall-damage toggle (`SetPedCanRagdoll` family) | no | yes | `Open77.players.setFallDamage(id, false)` — fall damage **only**, not god mode — [Player stats](player-stats.md#fall-damage) | | `NetworkGetEntityOwner` / `NetworkRequestControlOfEntity` | no | yes | `Open77.vehicles.owner(id)` / `Open77.npcs.owner(id)` and `Open77.vehicles.requestAuthority(id, playerId)` — a request the election validates, refused by name (`driven`) rather than a forced steal | | `PlayPedAmbientSpeechNative(ped, context, …)` | no | yes | `Open77.npcs.speak(id, voice)` — a `voContext` name against the NPC's own voiceset; `true` means queued on every viewer's puppet, and a name the voiceset lacks is silent without a word — [NPC behaviour](npc-behavior.md#speech-one-line-on-demand) | | `SetPedComponentVariation` on an **NPC** | no | plumbing | `Open77.npcs.setEquipment(id, { slot = record })` replicates a canonical clothing set, but measured on 2.31 a `Character.*` rig renders none of it: `true` is not a visible outfit — [NPCs](npcs.md) | | `GiveWeaponComponentToPed` / grenades in a throwable slot | yes | yes | `Open77.weapons.setComponent` / `removeComponent` / `components` and `giveGadget` / `takeGadget` / `gadgets`, with `onGadgetConsumed` on the owner's report — [Weapon Lua API](weapons-api.md) | | `screenshot-basic` (`requestScreenshot`, `requestScreenshotUpload`, `requestClientScreenshot`) | yes | yes | client `Open77.screen.capture` + `Open77.screen.upload` (the game's own back buffer, never the desktop); server `Open77.players.requestScreenshot(id, { url })`, delivered by the client to a URL — the picture never crosses the game transport — [Screenshots](screenshots.md) | | `RegisterPedheadshot` (mugshots) | yes | no | `Open77.screen.mugshot(entity, options)` — a remote player or an NPC is a framed face; the **local player in first person has no body to photograph** — [Screenshots](screenshots.md) | | `RequestModel` / `HasModelLoaded` / `RequestAnimDict` / `SetModelAsNoLongerNeeded` | — | — | **not needed**, and not provided — see [What is missing](#what-is-missing-and-why) | | `SetBlockingOfNonTemporaryEvents` / `SetPedKeepTask` / `SetEntityAsMissionEntity` | no | yes | three NPC settings that already exist under other names — see [Keeping an NPC on task](#keeping-an-npc-on-task) | | `DrawText`, `DrawText3D`, `DrawMarker`, `DrawLine`, `DrawSprite` | yes | — | the UI kit and the world-drawing namespaces — see [Porting FiveM draw calls](#porting-fivem-draw-calls) | | `onResourceStarting` / `playerEnteredScope` / `playerLeftScope` | no | yes | the same names, host-wide and reserved: `onResourceStarting(name)` is delivered inline while the resource still reads `starting` (a `CancelEvent()` inside it is refused by name, never ignored), and the scope pair is published under both spellings (`onPlayerEnteredScope` too) — [Scope events](server-api.md#scope-events) | Failures follow the repository convention: `nil, reason` for a function that returns a value, `false, reason` for a predicate, with stable snake_case reason tokens. ## Threads and ticks `Citizen.CreateThread`, `Citizen.Wait`, `Citizen.SetTimeout` and `Citizen.ClearTimeout` are the *same function values* as the bare globals — `Citizen.CreateThread == CreateThread` is `true` — so there is no second scheduler and no second set of budgets. ```lua Citizen.CreateThread(function() while true do Citizen.Wait(500) Citizen.Trace("still here") end end) ``` ### `SetTick(fn) -> id` and `ClearTick(id) -> boolean` A tick is an ordinary scheduler task looping with an implicit `Wait(0)`: it runs once per host tick (per frame on the client, per scheduler pass on the server), is bounded by the same per-frame budget as any other task, and is counted against the same task cap. Stopping the resource stops it. ```lua local id = SetTick(function() -- runs every frame end) ClearTick(id) -- true the first time, false afterwards ``` `SetTick` returns `nil, "invalid_tick_function"` when the argument is not a function, and `nil, "task_limit_or_stopping"` when the resource is at its task cap or shutting down. **Errors inside a tick do not kill the VM, and are not ignored either.** Each failing pass is logged at error level with the Lua error; after **five consecutive** failures the tick logs `tick cancelled after 5 consecutive failures` and cancels itself. One successful pass resets the count. The reason for cancelling rather than logging forever: a tick that throws every frame otherwise fills the log and keeps spending its slice of the frame budget for the whole session. Cancellation takes effect on the next pass, on both sides: `fn` never runs again after `ClearTick` returns. ### `GetGameTimer()` Monotonic milliseconds, on both sides. The client reads the same clock as `Open77.time.monotonic()`, which reports seconds; the server returns the value the host hands its scheduler each tick. Like FiveM's, the epoch is arbitrary and is **not** wall clock: only the difference between two readings means anything, and the client and the server share no origin. For a wall clock the server has `GetUnixTime()` and `GetUtcTimestamp()`. ## Promises `promise.new()` builds the **same** object `Open77.exports.call` returns, so there is one promise type in the runtime rather than two. ```lua local p = promise.new() CreateThread(function() Wait(250) p:resolve("done") -- or p:reject("no_such_thing") end) CreateThread(function() local value, reason = Citizen.Await(p) -- identical to p:await() print(value, reason) end) ``` | Method | Result | |---|---| | `p:resolve(...)` | `true`, or `false, "promise_already_settled"` / `"promise_cancelled"` | | `p:reject(reason)` | same shape; a non-string reason becomes `"promise_rejected"` | | `p:await()` | the resolved values, or `nil, reason` when rejected | | `p:status()` | `"pending"`, `"resolved"`, `"rejected"`, or `"cancelled"` | | `p:next(onResolved, onRejected)` | a scheduler task id, or `nil, reason` | Four things to know before porting promise-heavy code: - **`await` needs a managed coroutine.** Called from script load, or from anything that is not a scheduler task, it returns `nil, "await_requires_scheduler_coroutine"` rather than parking the VM. `Citizen.Await(p)` is exactly `p:await()` and behaves identically. - **`:next` is not chainable.** It returns the id of the task it schedules, not a new promise, so `p:next(a):next(b)` does not work. Sequences belong in a `CreateThread` with `:await()`. The id is useful to cancel a continuation that is still waiting (`ClearTimeout`). - **`:next` recognises a rejection by shape**: the `nil, ` pair `:await()` returns for one. That is this codebase's failure convention everywhere, so a promise deliberately resolved with a leading `nil` followed by a string is read as rejected. Resolve with a table, or use `:await()` directly, if that distinction matters. - **On the client a pending promise has one consumer.** The first `:await()`/`:next()` woken by a settle takes the value, and the promise then reports `cancelled`; awaiting an *already settled* promise can be repeated. This is the existing client export-promise behaviour, not something the alias layer introduced. On the server a `promise.new()` promise keeps its value and can be awaited repeatedly. Code that awaits once is portable across both. - **Resolved values cross a serialization boundary on the client** (the same one exports use), so a value that cannot be serialized is refused with `false, "promise_value_not_serializable"` rather than silently dropped. The server passes Lua values through untouched. ## Shared scripts ### `IsDuplicityVersion()` `true` in the server VM, `false` in the client VM. This is the branch a `shared_script` uses: ```lua if IsDuplicityVersion() then RegisterCommand("ping", function(source) print(source) end, false) else RegisterNetEvent("app:pong", function() print("pong") end) end ``` ### `LoadResourceFile(resourceName, path)` and `SaveResourceFile(...)` `LoadResourceFile` reads a file belonging to the **calling** resource and nothing else. Naming another resource returns `nil, "cross_resource_read_denied"` — it does not quietly read your own file, because that would make ported code look like it works. | | Client | Server | |---|---|---| | Root | the resource directory (`Open77.resource.readFile`) | the resource's `data/` directory (`Open77.io.read`) | | Permission | none | `filesystem.read` | | Size cap | 1 MiB | the `Open77.io` cap | | Escapes | `nil, "invalid_resource_path"` | `nil, "path_outside_resource"` | `SaveResourceFile(resourceName, path, data)` exists on the **server only**, maps to `Open77.io.write`, needs `filesystem.write`, and returns `true` or `false, reason`. There is no client equivalent: a client resource writes through `Open77.kvp`, which is the persistence the client actually has. ### `GetHashKey(str)` / `joaat(str)` — a TweakDBID, not a GTA hash **Hash compatibility.** In FiveM `GetHashKey` is the Jenkins one-at-a-time hash (`joaat`) of a GTA model or entry name. Open77's records are TweakDB strings — `"Vehicle.v_sport2_quadra_turbo_r"`, `"Items.Preset_Lexington_Chrome"` — so the only hash that resolves to anything in this game is the one REDengine computes itself: ```text TweakDBID = CRC-32/ISO-HDLC(name) in the low 32 bits | (length(name) & 0xFF) in byte 4 ``` Both runtimes compute the identical value (the client in C++, the server in Lua), so a hash crossing the wire means the same thing on both sides: ```lua GetHashKey("Vehicle.v_sport2_quadra_turbo_r") --> 133740073284 (0x1E23879144) GetHashKey("a") --> 8199323203 GetHashKey("") --> 0 ``` `joaat` is the same function under the FiveM name, kept so ported code reads naturally — but it is **not** the Jenkins hash the name suggests. A hash constant copied out of a FiveM resource names a GTA model and matches nothing here; port the *string*, not the number. ### `DoesEntityExist(kind, id)` FiveM has one opaque entity handle space. Open77 has one registry per kind, and they do not share an id space, so the kind has to be named: ```lua DoesEntityExist("vehicle", vehicleId) --> boolean DoesEntityExist(vehicleId) --> false, "kind_required" ``` The one-argument FiveM form cannot be answered and is refused rather than guessed at. | `kind` | Client | Server | |---|---|---| | `"vehicle"` | `Open77.vehicles.get` | `Open77.vehicles.get` | | `"npc"` | `Open77.npcs.get` | `Open77.npcs.get` | | `"elevator"` | `Open77.elevators.get` | `Open77.elevators.get` | | `"prop"` | the resource's own prop list | `Open77.props.get` | | `"effect"` | the resource's own VFX/SFX lists | `Open77.effects.get` | | `"player"` | the replicated health roster | `GetPlayers()` | | `"loot"` | `false, "kind_unavailable_on_client"` | `Open77.loot.get` | Reasons: `"kind_required"`, `"id_required"`, `"invalid_entity_id"`, `"unknown_entity_kind"`, `"kind_unavailable_on_client"`. Each kind answers through the registry that already owns it, **with that registry's permissions**: a resource that may not read vehicles gets `false`, the same answer an absent id gets. If you need to tell "denied" from "gone", call the namespace function directly — it returns the reason. The client has no loot registry it can be asked about (the server owns that list), so the client says so instead of answering `false`, which would read as "the drop is gone". ### `SetEntityInvincible(kind, id, enabled)` The same divergence, on the one write FiveM scripts make through an entity handle. Server only, because damage immunity is a server ledger here: ```lua SetEntityInvincible("player", playerId, true) --> true (Open77.players.setGodMode) SetEntityInvincible("player", playerId, false) --> true SetEntityInvincible(playerId, true) --> false, "kind_required" SetEntityInvincible("vehicle", vehicleId, true) --> false, "unsupported_entity_kind" ``` For `"player"` it **is** `Open77.players.setGodMode` — the same `players.stats.apply` permission (refused as `permission_denied:players.stats.apply` before any id is looked at), the same combat ledger bit, read back as `Open77.players.get(id).godMode`. Every other kind is refused **by name** rather than accepted and ignored: a vehicle's damage is its own scopes (`setEngineHealth`, the body zones), an NPC's is its `damagePolicy`, and a ported script that expects an indestructible car should find that out here rather than on the first hit. A name that is no kind at all is `unknown_entity_kind`; a non-boolean `enabled`, or a player the ledger does not hold yet, is `invalid_argument`. Two neighbours it is not: `Open77.players.setFallDamage(id, false)` switches fall damage off and nothing else, and the grav-chute's shield is a blanket *Invulnerable* that lives only while the chute is armed — [Player stats](player-stats.md#fall-damage) draws the line. ### ACE checks (server) `IsPlayerAceAllowed(playerId, permission)` is `Open77.acl.isAllowed` under the FiveM name: same `acl.read` permission, same session player id, same answer. `IsPrincipalAceAllowed(userId, permission)` asks the same question addressed by **account id**. FiveM principals are durable; Open77 resolves permissions through the connected session, so a user who is not connected cannot be answered and the call returns `false, "principal_not_connected"` rather than a bare `false` that would read as "denied". ### Player identifiers (server) ```lua GetPlayerIdentifierByType(src, "open77") --> "11111111-2222-3333-4444-555555555555" GetPlayerIdentifierByType(src, "userId") --> the same installation identity GUID GetPlayerIdentifierByType(src, "name") --> "Valerie" GetPlayerIdentifierByType(src, "fingerprint") --> "sha256:..." GetPlayerIdentifierByType(src, "license") --> "aaaaaaaa111122223333bbbbbbbbbbbb" GetPlayerIdentifierByType(src, "steam") --> "11000010000002a" (hexadecimal, if linked) GetPlayerIdentifierByType(src, "gog") --> "12345678901234567" (decimal, if linked) GetPlayerIdentifiers(src) -- Example with both stores linked (either store may be absent): --> { "open77:1111...", "name:Valerie", "fingerprint:sha256:...", -- "license:aaaaaaaa111122223333bbbbbbbbbbbb", -- "steam:11000010000002a", "gog:12345678901234567" } ``` `GetPlayerIdentifierByType` returns the **bare** value; `GetPlayerIdentifiers` returns the FiveM shape, an array of `"type:value"` strings. Iterate safely with `for _, id in ipairs(GetPlayerIdentifiers(src) or {}) do ... end`. The array read returns `nil, "player_not_found"` for an unknown session. The typed read returns `nil, "identifier_not_linked"` when the requested `license`, `steam` or `gog` value is unavailable. Type names are case-insensitive; unsupported types return `nil, "unknown_identifier_type"`. `license`, `steam` and `gog` require server runtime `2.31.13+op77.101` or later. Use `license` as the permanent Open77 account key across linked devices; keep all identifiers as strings. One Steam or GOG account owning both Cyberpunk 2077 and Phantom Liberty is verified once and linked permanently. Since runtime `2.31.13+op77.102`, an account manually approved by an Open77 administrator can also connect with `license` present and both store identifiers absent. Resources must support this case. No Discord or Xbox identifier is exposed. Store linking is currently optional; players who choose **Skip for now** also have `license` without either store ID. Existing verified store links remain available. See [verified account and store identifiers](connection-control.md#steam-gog-and-permanent-account-identifiers) for the server event example, formats and ownership semantics. ### Commands on the client `RegisterCommand` exists on **both** runtimes now, with the same handler shape `(source, args, rawCommand)`. The client passes `source = 0`, because on the client there is no other party to attribute a command to; a resource that wants the local session id calls `Open77.session.playerId()`. ```lua -- client script RegisterCommand("hud", function(_, args) Open77.hud.setVisible(args[1] ~= "off") end, false, { help = "Show or hide the HUD.", parameters = { { name = "state", optional = true } } }) ``` The fourth argument is an Open77 addition, not a FiveM one: it is the completion entry the chat composer shows. A port that passes three arguments is accepted unchanged and simply gets a generic completion line. **`restricted = true` on the client refuses rather than pretends.** The command is registered and listed, and then declines to run with `command_restricted`. The client has no ACL — it cannot answer an authority question — so enforcing nothing while claiming to enforce something would be the worse failure. That is also what FiveM effectively does for a player who does not hold the ace. An admin-only command belongs on the server, where `command.` is a real check. `ExecuteCommand(line)` differs from FiveM's deliberately: **it resolves against the client registry only and reports whether it matched**, rather than silently forwarding an unrecognised line to the server. `false, "unknown_command"` is the signal a caller uses to decide to forward, and that is exactly what the chat composer does — it tries the client first, forwards on `unknown_command`, and shows any other refusal. A composer that could not tell "no client command" from "the client command failed" would either swallow errors or spend a net event on every keystroke. `GetRegisteredCommands()` returns `{ name, resource, restricted, help?, parameters? }` on the client — two fields more than FiveM's `{ name, resource }`, because the composer needs them to build a completion. The server's returns the same shape plus `source`, which says whether a command came from a Lua resource or from the operator console. ### Convars, and why tunables are better `GetConvar`, `GetConvarInt`, `GetConvarBool` and `SetConvar` read a `convars` block of the server's configuration, then fall through to **the calling resource's own declared tunables by exact key**, then to the caller's default. A getter never raises. `SetConvar` is a server-wide, in-memory override that does not survive a restart. They exist so a port runs. They are not the mechanism to reach for when writing something new: `Open77.tunables` is typed, range-checked, validated on every write, retunable by the operator from the Warden panel while the server runs, persisted, scoped to one resource, and it notifies its owner when a value changes. The fall-through to tunables is deliberate — it lets a ported resource be migrated one key at a time instead of all at once. See [the server API reference](server-api.md#convars). ### `ExecuteCommand` does not carry rcon This is the one place Open77 refuses to copy FiveM, and it is worth reading before porting an admin resource. In FiveM, `ExecuteCommand` runs as the console: full authority, with nothing anywhere declaring that the resource holds it. Here it is split in two. `runtime.commands` in the manifest lets a resource run another resource's **ordinary** command and nothing else — a restricted command and every resource-lifecycle verb are refused. `resources.control` raises that to the operator's authority and is separately required by `StartResource` / `StopResource`. An operator installing a resource therefore sees the escalation in the manifest before running it. Commands are also **queued**, not run inline, and report acceptance rather than completion: Lua runs inside the server's tick, and a resource stopping itself synchronously would free the Lua state doing the stopping. The reply goes to the server log. ### Player identifiers, ping and endpoint (server) `GetPlayerPing(playerId)` takes no capability — latency is not identity, and every scoreboard in the genre shows it. It answers `nil, "ping_unavailable"` on a transport that cannot measure, rather than a plausible zero. `GetPlayerEndpoint(playerId)` **does** take one: `players.identity.sensitive`. An endpoint is an IP address. FiveM gates nothing here; a port that quietly logged addresses will start reporting `permission_denied:players.identity.sensitive` until its manifest says what it is doing. `Open77.players.identifiers(playerId)` is the table companion to the array `GetPlayerIdentifiers` returns: `{ open77, userId, name, fingerprint, joinedAt, license?, steam?, gog? }`, plus `endpoint` only when `players.identity.sensitive` is held. Account and store identifiers need no permission. Unavailable fields are omitted and read as `nil` in Lua. ### txAdmin events, under Open77 names A ported script that listens for `txAdmin:events:scheduledRestart` or `txAdmin:events:serverShuttingDown` finds the same information under the `open77:admin:` prefix. Rename the handler; the shapes are close but not identical. | txAdmin | Open77 | Difference | |---|---|---| | `txAdmin:events:scheduledRestart` | `open77:admin:scheduledRestart(secondsRemaining, reason)` | Arguments, not a table. Raised on each rung of the warning ladder. | | `txAdmin:events:serverShuttingDown` | `open77:admin:serverShuttingDown(reason)` | `restart`, `reset` or `shutdown`. | | `txAdmin:events:announcement` | `open77:admin:announcement(text)` | Same. | | `txAdmin:events:playerWarned` | `open77:admin:playerWarned(playerId, author)` | **No reason and no operator name** — see below. | | `txAdmin:events:playerKicked` | `open77:admin:playerKicked(playerId, author)` | Same. | | `txAdmin:events:playerBanned` | `open77:admin:playerBanned(playerId, author, durationSeconds)` | Same, plus the duration. | | `txAdmin:events:healedPlayer` | `open77:admin:playerHealed(playerId, author)` | Raised by Warden's **heal** action only — see below. | Two differences will bite a straight port. **The moderation reason is not carried**, and `author` is a channel (`warden` or `resource`) rather than a staff name: these events reach every resource on the server, and a host-wide event carrying either would be a moderation log for anything that cared to listen. A script that displayed the reason must get it from its own admin resource instead. And **`healedPlayer` fires only for Warden's own heal**. txAdmin has it because healing is something its panel does — and since the [Players tab](warden-players.md) grew a heal action, Warden's panel does too, so `open77:admin:playerHealed(playerId, author)` is raised when an operator heals from it. It is **not** raised when a resource calls `Open77.players.heal`: 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. An admin resource that heals on command should publish its own event, under its own namespace — `open77:admin:` is reserved to the platform precisely so that "the operator's surface did this" stays a claim only the platform can make. The full contract, including the `Open77.runtime.scheduleRestart` lever, is in [Admin events](server-api.md#admin-events). ### Damage feedback events (client) FiveM's `gameEventTriggered` with `CEventNetworkEntityDamage` is one firehose every combat HUD filters. Open77 splits it into three client events, all raised from the server's authoritative health verdict — the `PlayerHealthState` broadcast, never the shooter's local raycast — so a hitmarker drawn from `hitConfirmed` is a hit the ledger credited: | Event | Raised on | Arguments | |---|---|---| | `open77:localDamaged` | the **victim's** client, when the local player lost health | `attackerId, amount, dirX, dirY, dirZ, bodyPart, health, maxHealth` | | `open77:hitConfirmed` | the **attacker's** client, when the server credited a hit by the local player on a *player* | `victimId, amount, bodyPart, lethal` | | `open77:playerDamaged` | every client that has the victim's proxy — the observer feed for nameplates and kill feeds | `victimId, attackerId, amount, bodyPart, health, maxHealth` | ```lua AddEventHandler("open77:hitConfirmed", function(victimId, amount, bodyPart, lethal) showHitmarker(lethal == "1") -- a string, not a boolean end) AddEventHandler("open77:localDamaged", function(attackerId, amount, dirX, dirY, dirZ) showDamageDirection(tonumber(dirX), tonumber(dirY), tonumber(dirZ)) end) ``` Three traps, each found while building a combat HUD rather than imagined: - **`hitConfirmed` is attacker-side and player-victim only.** It is raised from the player-health drain, so a hit on a server-owned NPC produces no hitmarker at all. For NPCs the shooter's client gets `open77:npcHit(npcId, damage, hitZ, weaponTdbId, attackKind)` instead, and a gamemode that credits the hit server-side emits its own marker event from there. - **Every argument is a string**, like every engine-raised event: `lethal` arrives as `"1"` / `"0"`, and `amount`, `health`, `maxHealth` and the direction need `tonumber`. - **The server event of the same name has a different list.** Server-side `open77:playerDamaged` carries `(victimId, attackerId, amount, attackKind, weaponTdbId, bodyPart, remainingHealth, maxHealth, lethal, downedHit)`; a handler copied from one side to the other reads the wrong column. Both lists live in [Player stats](player-stats.md#events). ### Keeping an NPC on task Three FiveM natives keep a ped from wandering off, and each is a setting that already exists here under another name. Nothing was built for them: | FiveM | Open77 | What it actually does | |---|---|---| | `SetBlockingOfNonTemporaryEvents(ped, true)` | `Open77.npcs.setPerceptionEnabled(id, false)` | Stops autonomous stimulus acquisition, including Open77's own target seeding. It does **not** drop a target already acquired — add `Open77.npcs.setCombatEnabled(id, false)` for that. `setAIEnabled(id, false)` is the wider switch: the native agent stops too, and the NPC's simulation lease is revoked. | | `SetPedKeepTask(ped, true)` | the task's own `timeoutMs = 0` | A task without a timeout runs until it succeeds, fails or is cancelled; the channel keeps it across ownership moves. There is no separate "keep" bit because nothing here clears a task behind a script's back. | | `SetEntityAsMissionEntity(entity, true, true)` | `persistent = true` and `despawnWhenUnobserved = false` on `Open77.npcs.create` (and `setPersistent` on vehicles) | **Cleanup policy only**: the NPC survives its resource stopping and is not despawned when no client observes it. It is not saved anywhere — server persistence is the resource's own storage. | The three switches are documented with their side effects in [NPC behaviour](npc-behavior.md) and the task options in [NPCs](npcs.md#tasks). ### Porting FiveM draw calls FiveM draws with per-frame natives — a thread that calls `DrawText` every tick keeps the text on screen. Nothing here is per-frame: every drawing surface takes a description once and returns a handle, and the loop goes away with the port. | FiveM | Open77 | The difference that matters | |---|---|---| | `DrawText` / `SetTextFont` / `SetTextEntry` in a tick | `exports.open77_uikit:textUI({ text, position, key, icon })` | One persistent hint slot per resource (`top` / `center` / `bottom`); a second call **replaces** it; `hideTextUI` removes it. Four resources may show one at once. | | the `DrawText3D` pattern (project a coordinate every frame, draw text at it) | `exports.open77_uikit:drawText3D({ position \| entity, text, sublabel, maxDistance, ttl })` → `updateText3D` / `clearText3D` | A native world anchor of render style `card`, projected by the host with the distance band, viewport cull and occlusion the interaction prompts already pay for. **Budgeted:** the kit refuses 8 per owner (`text3d_owner_limit`) and 24 in all (`text3d_limit`), under the anchor quota of 32 per resource / 128 global — draw the nearest eight, not one per entity. | | `DrawMarker` | `Open77.markers.*` | A shape/style vocabulary, ground circles through [`open77_groundcircle`](https://github.com/Open2077/open77-resources/tree/main/system/open77_groundcircle). | | `DrawLine`, `DrawPoly`, `DrawBox` (zone debug) | `Open77.debugDraw.set(tag, geometry)` / `clear(tag)` | Resource-owned lines and triangles under a tag, replaced atomically per call and kept until cleared; permission `world.debug`. | | `DrawSprite`, `DrawRect` | an `` or a `
` on a WebUI page | Screen-space art is a page; a rectangle pair every cutscene draws for bars is `exports.open77_uikit:showCinematicBars(true)`. | `Open77.anchors` underneath can render a `card`, `ring`, `dot` or `page` at a world point when the kit's text is not enough. The kit's own porting notes are in [UI kit — porting a `DrawText3D` loop](ui-kit.md#porting-a-drawtext3d-loop). ### Manifest keys A FiveM `fxmanifest.lua` ports to `open77.lua` mostly by renaming the file; the keys that carry meaning here are handled, the rest are named once and ignored: | FiveM key | Open77 | |---|---| | `exports { ... }` / `server_exports { ... }` | Accepted as-is: each side pre-registers the listed **global functions** before the resource is `Running`, through the same registration a scripted `exports()` call performs. A listed name with no global function refuses the start (`manifest_export_missing:`). | | `ui_page 'html/index.html'` | An alias of `web_ui_page`, with the same rule that the file is declared in `web_files`. When both are written, the later line wins. | | `fx_version`, `game`, `games`, `lua54`, `use_experimental_fxv2_oal`, `author`, `description`, `provide`, `provides`, `escrow`, `escrow_ignore` | **Accepted and ignored**, and named once per start so the acceptance is never mistaken for support: `INF\|my_resource\|manifest_ignored_keys=fx_version,lua54`. | | `client_script '@ox_lib/init.lua'` (cross-resource includes) | **Refused by name**, `cross_resource_include_refused:@ox_lib/init.lua`, at manifest parsing on both sides. Scripts run in their own VM; share code with `dependency` plus `require('@resource/module')` on the client and exports on both. | The full manifest reference is [Server resources](server-resources.md#declarative-exports-and-ported-manifests). ## What is missing, and why **`Citizen.CreateThreadNow` is unavailable.** Both schedulers start tasks on the next pass. Use `Citizen.CreateThread`, or call non-yielding code directly. `Citizen.InvokeNative`, `Citizen.CreateUThread`, `Citizen.Wait` inside a non-managed coroutine, and the `msgpack` surface are not provided: Open77 has no GTA native table to invoke, and the event payload encoding is not msgpack. **`RequestModel`, `HasModelLoaded`, `RequestAnimDict`, `HasAnimDictLoaded`, `SetModelAsNoLongerNeeded` and `RequestNamedPtfxAsset` are unavailable.** The server registry controls vehicle and NPC streaming. `create` returns before the client projection is attached; check whether the entity is streamed on the current client: | A ported loop waited on | Ask instead | |---|---| | `RequestModel(model)` … `while not HasModelLoaded(model) do Wait(0) end` before a vehicle | `Open77.vehicles.whenStreamed(id, timeoutMs):await()` — resolves with the `get(id)` snapshot once `streamed == true`, rejects `timeout`; the state is `Open77.vehicles.get(id).streamed`, the transition is `open77:vehicleCreated` | | the same before a ped | `Open77.npcs.whenReady(id, timeoutMs):await()` — resolves with `{ id, entity }` the instant `onNpcReady` fires (attached **and** behaving); the state is `Open77.npcs.isStreamedIn(id)`; the server has the same helper, resolved by the first client that reports the body | | `RequestAnimDict` before `TaskPlayAnim` | nothing: `Open77.animations.play` resolves a clip from the catalogue itself | | `RequestNamedPtfxAsset` before a particle | nothing: `Open77.vfx.*` names a curated effect and the engine owns the asset | Delete the loop rather than emulating it: a `HasModelLoaded` that always answered `true` would have ported code spawning a blip on an entity that is not there yet. ## Verifying a port A shared script that prints this block should produce the same output on both runtimes, except for `duplicity`. It is what the client and server test suites assert: ```lua print("parity duplicity=" .. tostring(IsDuplicityVersion())) print("parity hash=" .. tostring(GetHashKey("Vehicle.v_sport2_quadra_turbo_r"))) print("parity joaat=" .. tostring(joaat("x") == GetHashKey("x"))) print("parity citizen=" .. tostring(Citizen.CreateThread == CreateThread) .. "," .. tostring(Citizen.CreateThreadNow == nil)) print("parity timer=" .. type(GetGameTimer())) local _, kindError = DoesEntityExist(1) print("parity kind=" .. tostring(kindError)) local _, crossError = LoadResourceFile("somewhere_else", "note.txt") print("parity cross=" .. tostring(crossError)) local settled = promise.new() settled:resolve("parity") print("parity promise=" .. tostring(settled:status())) local _, awaitError = Citizen.Await(promise.new()) print("parity await=" .. tostring(awaitError)) print("parity tick=" .. type(SetTick) .. "," .. type(ClearTick)) ``` ```text parity duplicity=false (client) | parity duplicity=true (server) parity hash=133740073284 | parity hash=133740073284 parity joaat=true | parity joaat=true parity citizen=true,true | parity citizen=true,true parity timer=number | parity timer=number parity kind=kind_required | parity kind=kind_required parity cross=cross_resource_read_denied| parity cross=cross_resource_read_denied parity promise=resolved | parity promise=resolved parity await=await_requires_scheduler_coroutine (both) parity tick=function,function | parity tick=function,function ``` ## See also - [Complete server Lua API](server-api.md) — every server global and `Open77.*` namespace. - [Cross-resource server exports](server-exports.md) — where the promise type comes from. - [Server resources](server-resources.md) — manifests, permissions, and the resource lifecycle. --- Source: https://open2077.net/docs/identity # Player identity and username Each installation has a persistent cryptographic identity. The Master verifies the username used by chat, nameplates, presence events and server scripts. ## Stable id and display name Identifiers have different jobs: | Value | Lifetime | Use | |---|---|---| | `license` | Permanent Open77 account | Characters, inventories and progression across linked devices | | `userId` / `open77` | Persistent installation identity | Existing identity-based APIs, including built-in access lists and ACLs | | `steam` / `gog` / `epic` | Linked store account | Store identity supplied by the verified Master ticket; Epic needs v3 | | `playerId` / `source` | One connection | Addressing the player during the current session | | `displayName` | Editable profile field | Chat and presentation only | For account-level persistence, store `license` as a string. Use `userId` where an existing API requires the installation identity; the two values are not interchangeable. A player can rename their profile and receives a new `playerId` after reconnecting. Existing data keyed by `userId` needs an explicit migration before switching keys. `license`, `steam` and `gog` are available to server Lua since `2.31.13+op77.101`. See [Steam, GOG and permanent account identifiers](connection-control.md#steam-gog-and-permanent-account-identifiers) for a complete join handler, formats and missing-identifier behavior. Server `2.31.15+op77.105` adds Epic identifiers, with the matching Master update deployed. Older servers continue receiving v2 tickets: players can still join, but `epic` is absent there. Updating a server to read Epic IDs is optional. A missing store ID never removes the Open77 `license`. Steam/GOG ownership is verified once and remains linked to the Open77 account. Since `2.31.13+op77.102`, an account manually approved by an Open77 administrator can play without a linked store: `license` is present, while `steam` and `gog` may both be absent. Use the Open77 account key for progression rather than requiring a store ID. Linking is currently optional: players can skip it and still receive their permanent Open77 `license`. An absent Steam/GOG ID is a supported account state, not a failed connection. ## Changing the username The server browser shows the current username in its **Identity** field. Pressing **Save** sends a freshly signed enrollment request to the Master. The Master accepts the update only when the private key already attached to the `userId` signed it. Names contain 1 through 32 UTF-8 bytes and cannot contain control characters. A username cannot be changed during an active game session. The private key never leaves the Windows identity store. The request, response, and saved profile contain no reusable password. ## What a game server verifies The Master issues an Ed25519 certificate covering all three public profile values: ```text userId || P-256 public key || displayName ``` During connection, the game server verifies that certificate and a fresh P-256 session proof bound to its challenge. Changing only the name in a modified client invalidates the Master certificate, so the forged name is rejected before the session becomes active. ## Server Lua Server resources receive the verified values through the normal player API: ```lua AddEventHandler("onPlayerConnected", function(playerId, playerName) local player = tonumber(playerId) or 0 print(GetPlayerIdentifier(player)) -- installation userId print(GetPlayerName(player)) -- Master-verified displayName print(GetPlayerIdentifierByType(player, "license")) -- permanent Open77 account ID end) ``` The connect event is `onPlayerConnected`, and it passes the player ID as its first argument. `source` is **not** set here: it is populated only for handlers reached through a network event, never for a plain `TriggerEvent` dispatch. The username-editing Lua bridge is reserved for the trusted local server-browser package. Downloaded server resources cannot rewrite a player's identity. ## Language The game's language is a setting of the player's own game, not of their account: the engine stores it as three variables of the `/language` settings group -- `OnScreen` (text), `VoiceOver` (audio) and `Subtitles` -- whose values are CName codes such as `en-us`, `fr-fr`, `jp-jp`, `kr-kr`, `pt-br`, `zh-cn` (the very values the vanilla settings screen compares against; it is `ru-ru` that triggers the Russian voice-over disclaimer and `ar-ar` that flips the layout right-to-left). Open77 reads them where they live and reports them on both runtimes. **Client**, permission `session.locale`: ```lua local locale, reason = Open77.session.locale() -- { code = "fr-FR", gameLanguage = "fr-fr", voiceLanguage = "en-us", -- subtitles = "fr-fr", chromium = "en-US", revision = 1 } ``` `code` is a BCP-47 tag derived from `OnScreen` (the four languages the engine spells by country are mapped: `jp`→`ja`, `kr`→`ko`, `cz`→`cs`, `ua`→`uk`; the region is upper-cased). The three raw engine codes ride beside it, so a resource that wants the audio language, or meets a code the mapping has not seen, always has the source. `chromium` is the operating system's UI language -- what a WebUI page's `navigator.language` (the JS `Open77.getLocale()`) has always answered. It is **not** the game's language and is kept for contrast only. `nil, "locale_not_reported"` until the script bridge has answered once: the settings container is captured by the health-bar controller, so the first answer follows the first gameplay frame by a moment. The read is asked again, rate-limited, on every call, and at every world-ready. **Server**, permission `players.locale.read`: ```lua permissions { "players.locale.read" } AddEventHandler("onPlayerReady", function(playerId) CreateThread(function() Wait(5000) -- the report follows world-ready by a few seconds local locale = Open77.players.locale(playerId) local language = locale and locale.code:sub(1, 2) or "en" Open77.chat.send(tonumber(playerId), ({ fr = "Bienvenue !", de = "Willkommen!" })[language] or "Welcome!") end) end) ``` The server never asks the engine: the client **host** -- not a resource -- pushes the value once per world-ready on the reserved `open77:session:locale` net event, which the server consumes before any resource route and caches per session. `TriggerServerEvent` refuses the name on the client and `TriggerEvent` refuses it on the server (`reserved_event`), so what `players.locale` answers is what the player's own game said and nothing else. It is a report, not a ledger: a modified client can lie about its language, which costs nobody anything. The table adds `ageMs` (the age of the report) and `revision` (how many reports this session has produced). `GetPlayerLocale(playerId)` is the FiveM spelling of the same function. Four refusals, on purpose distinct: `invalid_player_id`; `player_not_found` for a player who is not connected; `not_reported` for one whose client has not pushed yet -- a client that never reached world-ready, or an older build; `locale_unavailable` in an embedding with no transport. **A language change re-reports.** The engine only allows changing the language at the main menu (the `/language` variables are pre-game only), so a player who changes it leaves the world to do so; the next world-ready pushes the new value, `revision` moves, and `ageMs` restarts. Nothing announces it: read the value when you need it rather than caching it for the session. --- Source: https://open2077.net/docs/connection-control # Connection control: connect events, whitelists and bans 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](server-api.md#connection-control) for method signatures. ## Where the gate sits A connection goes through these stages in order. Resources take part in the stages in bold. 1. The transport connects. Nothing is known about the player. 2. The client sends its hello: protocol, game build, display name, identity public key with a proof, and a Master connect ticket. 3. 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. 4. The server's own door list, `access.json` next to `server.jsonc`: a ban, or a closed whitelist the identity is not on, refuses here with the list's sentence. Warden, the console and `Open77.access` all edit the same list. See [The built-in door list](#the-built-in-door-list). 5. **`onPlayerConnecting`**, the resource gate. Every running resource that holds the `players.gate` permission and registered a handler is asked. Refuse, hold, or let through. 6. The player is admitted: welcome packet, player id, **`playerJoining`** and then **`onPlayerConnected(playerId)`**. 7. The join-time readiness gate (`Open77.ready`) and **`onPlayerReady`**. 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](server-api.md#join-time-readiness-gate). 8. The session ends: **`onPlayerDisconnected(playerId, reason)`** and **`playerDropped(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)` ```lua -- open77.lua permissions { "players.gate" } ``` ```lua -- 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:` 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 '': `. 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: ```lua 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](#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:`, `identity_proof_invalid`, `connect_ticket_required`, `connect_ticket_invalid`, `identity_key_changed`, `server_full`, `duplicate_client_hello`, `client_hello_required`. | ```lua 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`. ```lua 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: ```lua 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. ```lua -- 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. ```lua 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: ```lua -- 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](server-api.md#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 [label] whitelist.remove bans active bans ban.id [seconds|0] [reason] unban ``` **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:` as their author, so Warden shows which script let someone in. ```lua -- 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 [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 ```lua -- open77.lua resource "my_whitelist" version "1.0.0" auto_start true server_script "server/main.lua" permissions { "players.gate", "filesystem.read", "filesystem.write" } ``` ```lua -- 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 [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 ```lua permissions { "players.gate", "players.disconnect", "filesystem.read", "filesystem.write" } ``` ```lua 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 [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 [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: ```lua 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 `INF` as `Player '' () refused (): `. Progress notes appear as `connecting '': ` 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 through `Open77.io`, or in the database through `Open77.database`; both survive `ensure` and 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 | --- Source: https://open2077.net/docs/server-acl # Public identity, server commands, and ACL Register server commands with `RegisterCommand` and control access through ACL rules. Commands entered in the in-game developer terminal (`²`) are forwarded over the authenticated session when they are not handled locally. Use this guide to whitelist a player, restrict a command to specific people, and understand what identity the server actually trusts. ## Exporting your public identity In the Open77 terminal: ```text identity.dump ``` The reply gives the absolute path and the SHA-256 fingerprint: ```text OK identity_public_dumped path=".../red4ext/plugins/Open77/exports/identity-.json" fingerprint=sha256:... ``` The file holds the `userId`, the P-256 public key, its fingerprint, and a ready-to-copy `aclPrincipal` object. It never holds the private key, the DPAPI blob, the session proof, or the `%LOCALAPPDATA%/Open77/identity-v1.dat` file. Do not copy that last one to the server. ## Whitelisting a player The server loads the relative file configured in `server.jsonc`: ```json "accessControl": { "file": "acl.jsonc" } ``` Copy the exported `aclPrincipal` value into `principals`: ```json { "version": 1, "principals": [ { "name": "owner", "userId": "00000000-0000-0000-0000-000000000000", "publicKey": "base64...", "permissions": [ "command.loot.*" ] } ] } ``` Restart the server, or type `acl.reload` in its administration console. `acl.list` reports the path actually loaded; `acl.check ` is there for diagnosis. The comparison is made on the 64 bytes of public key certified during the handshake. If the entry also carries a `userId`, that must match too. The displayed nickname and the temporary `playerId` never take part in authorisation. ## Changing the ACL from a resource, at runtime The read half above is the whole story for a server whose rights are decided once and edited by hand. A job or gang model is not that server: "this player is on shift as police dispatch" is a fact that changes ten times an evening, and an operator should not be in the loop for any of them. `Open77.acl` therefore has a write half — native-parity row A12, FiveM's `add_ace`, `add_principal` and `remove_ace`. ```lua permissions { "acl.read", -- the existing read half "acl.grant:job.police.*", -- may hand out rights under this prefix "acl.define:job.police.*", -- may define roles whose rights are under it } ``` ```lua local userId = Open77.players.identity(source).userId assert(Open77.acl.definePermission("dispatcher", { "job.police.dispatch" })) assert(Open77.acl.addRole(userId, "dispatcher")) assert(Open77.acl.grant(userId, "job.police.impound")) -- Off shift. assert(Open77.acl.removeRole(userId, "dispatcher")) assert(Open77.acl.revoke(userId, "job.police.impound")) ``` | Function | Capability | Meaning | |---|---|---| | `Open77.acl.grant(userId, permission)` | `acl.grant:` | Add one permission. | | `Open77.acl.revoke(userId, permission)` | `acl.grant:` | Take it back. | | `Open77.acl.addRole(userId, role)` | `acl.grant:` | Put the user in a role. | | `Open77.acl.removeRole(userId, role)` | `acl.grant:` | Take them out of it. | | `Open77.acl.definePermission(role, permissions)` | `acl.define:` | Define, retune, or (with `nil`) delete a runtime role. | | `Open77.acl.grants(userId)` | `acl.read` | What the runtime layer holds for one user. | Every writer answers `true`, or `nil, reason`. A grant takes effect on the very next `isAllowed` — there is no reload and no restart, because the swap is in-process. ### The subject is a userId, never a playerId The read half takes a `playerId` because it asks a question about a seat that is occupied right now. The write half takes the **master-issued `userId`**, the string `Open77.players.identity(playerId).userId` answers, because a grant outlives the session that earned it and a seat number does not. It also means you can promote somebody who is offline. ### The delegation scope is the security model There is no bare `acl.grant` or `acl.define`. The capability *is* the pattern, and a resource may hand out exactly what is inside it: ```text permission "acl.grant:job.police.*" Open77.acl.grant(user, "job.police.dispatch") -- true Open77.acl.grant(user, "command.kick") -- nil, "permission_not_delegable" Open77.acl.grant(user, "*") -- nil, "permission_not_delegable" ``` This is the answer to the question A12 exists to raise: *may a resource grant a permission it does not itself hold?* It may grant whatever the operator wrote into its manifest, and nothing else — and the operator reads that delegation off the manifest before installing the resource, the same way `runtime.commands` and `resources.control` are readable there. **`acl.grant:*` is not a wildcard scope.** It is dropped, and a resource whose every entry was `*` is answered `scope_not_delegable`. Owner-equivalence comes from `acl.jsonc` or it does not come at all. Four more refusals close the escalations that follow from the first: | Reason | What it stops | |---|---| | `role_exceeds_scope` | `addRole` is checked against the role's **entire reach**, not its name. Without it a resource scoped to `job.police.*` would collect `command.*` by adding your `moderator` role — the same escalation one indirection later. | | `operator_principal` | No runtime call may name a subject your document binds to `*` or to the `owner` role, in either direction. | | `role_defined_by_operator` | A role you defined in `acl.jsonc` is yours. A resource cannot retune what it means. | | `role_reserved` | `owner` and `admin` are the server's, under any scope. | The capability that gates granting is a *manifest* permission, and manifest permissions are not in the ACL. So there is no string a resource can grant to anyone, itself included, that yields `acl.grant`. That one is structural rather than a check. ### Runtime grants live in a sibling, not in your file Writes go to **`acl.runtime.jsonc`**, next to `acl.jsonc`, written atomically through a temporary so a crash mid-write cannot truncate it. Your document is never touched. That is a security property, not tidiness. The overlay is **additive only** — it has no subtractive form at all — so no sequence of calls a resource can make, not even a compromised one holding the widest scope you can write, takes a right away from your document. A revoke that names one of your grants answers `operator_grant` instead of appearing to work. Had runtime grants been merged into `acl.jsonc`, a resource able to write that file would also be able to rewrite the owner's principal, and the whole model would rest on the refusals above being exhaustive rather than on the layout making the attack unrepresentable. Three consequences worth knowing: - **Effective rights are the union.** `isAllowed` checks your document first and the overlay second, and the overlay can only ever say yes. - **A complete reset is `rm acl.runtime.jsonc`.** One file, machine-owned, with a header saying so. Nothing of yours is caught in it. - **The overlay cannot bind a public key.** A runtime grant binds the master-issued user id only — the same strength as a userId-only principal in your document, which it already permits, and it keeps anything key-shaped out of a machine-managed file. A runtime role that collides with one you later define in `acl.jsonc` is dropped rather than merged: yours wins. ### `onAclChanged` Every accepted mutation publishes one host-wide event, on a reserved name a resource cannot forge. ```lua AddEventHandler("onAclChanged", function(revision, by, operation) -- revision: a counter, 1 per accepted mutation -- by: the resource that caused it -- operation: "grant" | "revoke" | "addRole" | "removeRole" | "definePermission" end) ``` It names **neither the subject nor the permission**, and that is deliberate: the event reaches every running resource, so a payload carrying either would tell the whole server what the ACL says while bypassing `acl.read`. A listener that only needs to drop a cache has everything it needs in the revision; one that is entitled to the detail calls `Open77.acl.isAllowed`, which is gated. One boundary to know: the event is published by the resource's own VM, so a grant made in a resource's **top-level chunk** — before the host has activated that VM on the event bus — persists and takes effect, but publishes nothing. Grant from a thread or an event handler if a listener has to see it. The same rule governs `Open77.environment.publishChange`. The Warden panel and the `acl.reload` console command write and reload `acl.jsonc` itself and do **not** publish `onAclChanged`; the event is about the runtime layer. ## Declaring a restricted command `RegisterCommand(name, handler, true)` declares a command that only a player holding the ACL right `command.` may run; the server refuses the others before the handler runs, and the dedicated console is always allowed. Grant the right in `acl.jsonc` (`command.settime`, or `command.*`), or let a resource grant it at runtime as described above. The refusal is reported to the caller through `open77:command:result`; a resource that wants to phrase its own refusal in chat registers the command unrestricted and rechecks `Open77.acl.isAllowed(source, "command.")` itself, as below. ### Reading effective rights from a server resource Declare `permission "acl.read"` in the resource manifest, then use: ```lua local allowed, error = Open77.acl.isAllowed(source, "command.admin.moderate.kick") local roles, error = Open77.acl.roles(source) ``` These APIs are server-only and read the compiled ACL for an authenticated active session. `isAllowed` accepts a concrete permission, not a wildcard; it returns false for an absent resolver/session. Invalid IDs (including 0), invalid names and missing manifest capability return `false, reason`. `roles` returns the matching role labels, or an empty array for an unknown session. Neither writes roles or grants rights. Continue registering privileged commands as restricted. The standalone [`open77_admin`](https://github.com/Open2077/open77-resources/tree/main/system/open77_admin) resource uses these bindings for `/admin` and rechecks commands at execution. See its README and role examples for deployment. Its `/pos` and `/rot` utilities intentionally require no admin ACL. ```lua RegisterCommand("garage.delete", function(source, args, rawCommand) -- source is the playerId of the authenticated session, or 0 for the server console. end, true) ``` From a client, this command requires `command.garage.delete`. An exact permission, `*`, or a trailing wildcard such as `command.garage.*` will grant it. The dedicated console uses `source=0` and stays authorised for local administration. The transport caps a line at 32 tokens and each token at 256 UTF-8 bytes, validates the command name, and reuses the network limit of 32 events per second. Refusals and results come back to the Open77 terminal through `open77:command:result`. --- Source: https://open2077.net/docs/callbacks # Network callbacks: asking the other side a question Network callbacks provide asynchronous request/response communication between client and server. A call returns an `Open77.Promise` that resolves with the response or rejects with an error. Callbacks serve the same request/response role as FiveM framework callbacks. Validate requests on the receiving side; callbacks do not grant additional authority. Callbacks ride the existing net-event transport, so they need the same `network.events` manifest permission and nothing more. They are **not** a new trust level: read [A callback is a request, never a grant](#a-callback-is-a-request-never-a-grant) before you write your first handler. ## The two directions Client asks, server answers — the shape you will use ninety per cent of the time: ```lua -- resources/shop/server.lua Open77.net.register('getStock', function(source, item) -- `source` is the authenticated player id. The client cannot forge it. if type(item) ~= 'string' or #item > 64 then return nil, 'invalid_item' end if not atTheCounter(source) then return nil, 'too_far' end -- re-validate, every time return stock[item] or 0, kPrices[item] end) ``` ```lua -- resources/shop/client.lua CreateThread(function() local count, price = Open77.net.callAwait('getStock', 'ripperdoc_kit') if count == nil then Open77.log.warn('stock lookup failed: ' .. tostring(price)) return end print(('%d in stock at %d eddies'):format(count, price)) end) ``` Server asks, client answers — for anything only the client knows (a UI choice, a progress bar the player can cancel, a local reading): ```lua -- resources/shop/client.lua (`confirm` is your own dialog, not a platform API) Open77.net.register('confirmPurchase', function(item, price) return confirm(('Buy %s for %d?'):format(item, price)) end) ``` ```lua -- resources/shop/server.lua local accepted = Open77.net.callClientAwait(source, 'confirmPurchase', item, price) if accepted ~= true then return end -- refused, timed out, or disconnected ``` ## API ### Server | Call | Meaning | |---|---| | `Open77.net.register(name, handler)` | Answer a client's `Open77.net.call`. `handler(source, ...)` returns any number of values. Returns `true`, or `nil, reason`. | | `Open77.net.unregister(name)` | Stop answering. Returns `true`, or `false, reason`. | | `Open77.net.callClient(playerId, name, ...)` | Ask one client a question. Returns an `Open77.Promise`, or `nil, reason`. | | `Open77.net.callClientAwait(playerId, name, ...)` | `callClient(...):await()`, for use inside a `CreateThread`. | | `Open77.callbacks.*` | Alias table carrying the same four functions. Pick one spelling per resource. | ### Client | Call | Meaning | |---|---| | `Open77.net.register(name, handler)` | Answer the server's `callClient`. `handler(...)` — no `source`, the caller is always the server. | | `Open77.net.unregister(name)` | Stop answering. | | `Open77.net.call(name, ...)` | Ask the server. Returns an `Open77.Promise`, or `nil, reason`. | | `Open77.net.callAwait(name, ...)` | `call(...):await()`, for use inside a `CreateThread`. | | `Open77.callbacks.*` | The same alias table. | The promise is the ordinary `Open77.Promise` you already get from [`Open77.exports.call`](server-exports.md): `p:status()` returns `pending`, `resolved`, `rejected` or `cancelled`, and `p:await()` yields inside a scheduler coroutine and returns the handler's values, or `nil, reason`. `await` outside a scheduler coroutine returns `nil, "await_requires_scheduler_coroutine"` rather than blocking the frame. Wrap it in `CreateThread`. ## Namespacing: a callback belongs to a resource `Open77.net.call("getStock")` from resource `shop` reaches the handler that resource `shop` registered **on the other side**. The client and server halves of one resource talk to each other and nobody else. To reach a different resource, name it: ```lua local promise = Open77.net.call({ resource = 'bank', name = 'balance' }) ``` Why not FiveM's flat global namespace, where `lib.callback('getStock')` reaches whichever resource happened to register that name? Because two resources that both want `getStock` then collide silently, and whichever started last wins for everyone. Open77's exports are already addressed as `(resource, name)` and its permissions are per resource; callbacks follow the same rule, with the common case — your own resource — as the default so nothing has to be spelled out. The cross-resource form is a **table**, not a second positional argument, because everything after the name is payload: a positional `resource` argument could never be told apart from a first argument that happens to be a string. Being reachable is not the same as being trusted. A cross-resource callback is still a request from a client; see the next section. ## A callback is a request, never a grant **The server is authoritative.** A callback handler is reached because a client asked it to be, with arguments the client chose. It must re-validate exactly what a `RegisterNetEvent` handler must re-validate: - **distance** — the player must actually be where the action requires; - **ownership** — the vehicle, the item, the container must be theirs; - **permission** — the job, the gang, the ACL entry; - **shape** — type, range and length of every argument, including nested tables. The single thing the client cannot choose is **who it is**. Inside a server handler, `source` is the authenticated player id, taken from the transport and never from the payload, exactly as `RegisterNetEvent` sets it. ```lua Open77.net.register('withdraw', function(source, amount) -- `source` cannot be forged. `amount` absolutely can be. if type(amount) ~= 'number' or amount ~= math.floor(amount) or amount < 1 or amount > 100000 then return nil, 'invalid_amount' end if not accounts[source] or accounts[source] < amount then return nil, 'insufficient' end accounts[source] = accounts[source] - amount return accounts[source] end) ``` One caveat worth knowing: use the `source` **argument**, not the global, in a handler that yields. The global is set the way `RegisterNetEvent` sets it, which means another task can overwrite it across a `Wait` or an `await`. The argument is yours for the life of the call. ## Timeouts Every call carries a deadline. The default is **10 000 ms**; override it with the table form: ```lua local promise = Open77.net.call({ name = 'getStock', timeout = 2000 }, 'ripperdoc_kit') local ok = Open77.net.callClientAwait(source, { name = 'confirm', timeout = 30000 }) ``` Accepted range: **100 ms to 120 000 ms**; anything else fails immediately with `invalid_callback_timeout`. A call that runs out of time rejects with `callback_timeout` — it is never retried, and the answer, if it ever arrives, is dropped. A **missing handler does not wait for the timeout.** A request for a name nobody registered, or for a resource that is not running, is refused on the next frame with `callback_not_found` or `callback_resource_unavailable`. A resource that stops or reloads answers everything it was still serving with `callback_target_stopped`, rather than leaving the other side to wait out its deadline. Its own pending promises die with its VM. The answering side has a deadline of its own. A handler that never returns -- an accidental `while true do Wait(1) end` -- would otherwise hold its slot for the life of the process, and 256 of them would refuse every further question to that resource forever. After 120 s, the longest any asker can still be waiting, the slot is reclaimed with `callback_handler_abandoned` and a warning in the log naming the callback. ## Limits Callbacks reuse the net-event envelope, so its caps apply unchanged, minus a three-slot frame header: | Limit | Value | Reason given | |---|---|---| | Arguments per call | 29 | `callback_too_many_arguments` | | Return values per answer | 29 | `callback_too_many_results` | | Payload size | 48 KiB per frame | `callback_payload_too_large` | | Table depth | 16 | `callback_arguments_not_serializable` | | Table nodes | 4096 | `callback_arguments_not_serializable` | | String length | 16 KiB | truncated by the envelope | | Registered names per resource | 256 | `callback_registration_limit` | | Questions open at once, per resource | 256 | `callback_request_limit` | | Questions being served at once, per resource | 256 | `callback_target_busy` | | Questions open at once, per host | 4096 | `callback_host_limit` | Exceeding a cap **rejects immediately**; nothing ever blocks or queues. The per-resource and per-host numbers mirror the export broker on purpose: one runaway resource cannot starve the others. Values cross the boundary as JSON, so the same rules as net events apply: strings (including embedded zero bytes), integers, floats, booleans, `nil` and tables of those. Functions, userdata and coroutines are not transferable. ## Failure reasons All of them are stable snake_case strings. Dispatch failures come back as `nil, reason` from `call`/`callClient`; everything else rejects the promise. | Reason | Meaning | |---|---| | `permission_denied:network.events` | The manifest does not declare the permission. | | `invalid_callback_name` | Empty, over 64 characters, or not a string. | | `invalid_callback_resource` | Empty or over 128 characters. | | `invalid_callback_timeout` | Outside 100–120 000 ms, or not a number. | | `invalid_player_id` | `callClient` needs a positive player id. | | `callback_broadcast_unsupported` | `callClient(-1, ...)` — see [Out of scope](#out-of-scope). | | `callback_not_found` | No handler with that name on the other side. | | `callback_resource_unavailable` | The named resource is missing, stopped, or lacks `network.events`. | | `callback_target_busy` | The answering side is already serving its maximum. | | `callback_target_stopped` | The answering resource stopped or reloaded mid-call. | | `callback_handler_abandoned` | The handler never returned; the answering side reclaimed its slot after 120 s. | | `callback_timeout` | No answer before the deadline. | | `callback_request_limit` / `callback_host_limit` | Too many questions open. | | `callback_too_many_arguments` / `callback_too_many_results` | Above the 29-value budget. | | `callback_payload_too_large` | Over 48 KiB once encoded. | | `callback_arguments_not_serializable` / `callback_results_not_serializable` | A value the envelope cannot carry. | | `callback_failed` | The handler failed and gave no text. | | `network_unavailable` | No session; nothing to ask. | | `callback_send_failed`, or the transport's own reason (`network_event_queue_full`, …) | The frame could not be handed to the transport. | | `resource_preparing` / `resource_stopping` | Called before start-up finished, or after stop began. | | `promise_cancelled` | The promise was released, or its resource stopped. | | `await_requires_scheduler_coroutine` | `await` outside a `CreateThread`. | | `reserved_callback_event` | Resource code tried to emit a `open77:callback:*` frame. | A handler that raises rejects the promise with **the error text**, and logs the failure on the handler's own side — the asker learns that it failed, the owner learns why. ## Reserved frames The transport owns two event names: ```text open77:callback:req the question open77:callback:res the answer ``` Resource code cannot emit them. `TriggerServerEvent`, `Open77.net.emitServer`, `TriggerClientEvent` and `Open77.net.emitClient` all refuse the `open77:callback:` prefix with `reserved_callback_event`, and the host consumes the frames before any Lua network handler is considered. Without that, any resource could forge an answer to another resource's pending question, or invent a question the server never asked. Use the API. Unlike an ordinary client event, a callback frame is **not broadcast to every resource**: it names the resource it is addressed to and goes only there. ## A worked example: a shop `resources/shop/open77.lua` ```lua resource 'shop' version '1.0.0' client_script 'client.lua' server_script 'server.lua' permissions { 'network.events' } ``` `resources/shop/server.lua` ```lua local kCounter = { x = -1580.0, y = -1290.0, z = 8.0 } local kCatalog = { medkit = 250, ammo_pistol = 40 } local stock = { medkit = 12, ammo_pistol = 400 } local function atTheCounter(playerId) local at = Open77.players.position(playerId) if not at then return false end local dx, dy, dz = at.x - kCounter.x, at.y - kCounter.y, at.z - kCounter.z return (dx * dx + dy * dy + dz * dz) <= 16.0 end -- A read. Still validated: a client can ask from anywhere, at any rate. Open77.net.register('catalog', function(source) if not atTheCounter(source) then return nil, 'too_far' end local rows = {} for item, price in pairs(kCatalog) do rows[#rows + 1] = { item = item, price = price, stock = stock[item] or 0 } end return rows end) -- A write. Every precondition is re-checked here, on the server, at the moment -- of the write -- not in the client that asked, and not in the earlier read. Open77.net.register('buy', function(source, item, count) if type(item) ~= 'string' or kCatalog[item] == nil then return nil, 'unknown_item' end if type(count) ~= 'number' or count ~= math.floor(count) or count < 1 or count > 10 then return nil, 'invalid_count' end if not atTheCounter(source) then return nil, 'too_far' end if (stock[item] or 0) < count then return nil, 'out_of_stock' end local cost = kCatalog[item] * count -- Confirm with the player before taking their money. 30 s, because a human -- is reading a dialog; the promise rejects on its own if they wander off. local accepted = Open77.net.callClientAwait(source, { name = 'confirm', timeout = 30000 }, item, count, cost) if accepted ~= true then return nil, 'cancelled' end -- Re-check after the yield: thirty seconds is a long time in a game world. if not atTheCounter(source) or (stock[item] or 0) < count then return nil, 'too_far' end stock[item] = stock[item] - count return count, cost end) ``` `resources/shop/client.lua` ```lua -- `confirmDialog` is this resource's own UI; returning false cancels the buy. Open77.net.register('confirm', function(item, count, cost) return confirmDialog(('Buy %d %s for %d?'):format(count, item, cost)) end) -- A client resource may RegisterCommand of its own since A8, but a key mapping -- stays the usual trigger for something with no arguments. RegisterKeyMapping('shop', 'Open the shop', 'F6', function() CreateThread(function() local rows, reason = Open77.net.callAwait({ name = 'catalog', timeout = 5000 }) if not rows then Open77.log.warn('shop: ' .. tostring(reason)) return end for _, row in ipairs(rows) do print(('%-14s %5d eddies (%d left)'):format(row.item, row.price, row.stock)) end local bought, cost = Open77.net.callAwait('buy', 'medkit', 2) if not bought then Open77.log.warn('purchase refused: ' .. tostring(cost)) return end print(('bought %d for %d'):format(bought, cost)) end) end) ``` Note what the server does **not** do: it never trusts the price the client read a moment ago, never trusts that the client is still at the counter after the confirmation dialog, and never trusts `count` because the earlier `catalog` call said the stock was there. A callback makes the round trip convenient; it changes nothing about who decides. ## Out of scope - **Broadcast callbacks.** `Open77.net.callClient(-1, ...)` is refused with `callback_broadcast_unsupported`. N answers do not fit one promise, and a resource that genuinely wants every client's answer can loop over `Open77.players.all()` and collect the promises itself. - **Client-to-client callbacks.** There is no peer transport; route through the server, which has to validate anyway. - **Callbacks between two server resources.** That is [`Open77.exports.call`](server-exports.md), which is in-process and does not touch the network. - **Bundled/system client resources.** Callbacks live in the server-resource host, the layer downloaded resources run in. ## See also - [Cross-resource server exports](server-exports.md) — the same promise type, between two server resources. - [Complete server Lua API](server-api.md) — every server global and `Open77.*` method. - [Connection control](connection-control.md) — `source`, identity and the connect gate. --- Source: https://open2077.net/docs/state-bags # Replicated state bags Replicate structured state with server-authoritative state bags. Resources can read snapshots and subscribe to changes instead of defining separate synchronization events for each value. ```lua -- server Open77.state.player(source):set("job", { name = "police", grade = 3 }) Open77.state.entity("vehicle", vehicleId):set("fuel", 61.5) Open77.state.global:set("weather", "rain") ``` ```lua -- client, anywhere, on any resource print(Open77.state.player(source).job.name) --> police print(Open77.state.global.weather) --> rain ``` That is the whole idea. Vehicle keys, fuel, cuffs, job, duty and engine state are all state-bag fields in a modern roleplay framework, and a bag replaces the ad-hoc broadcast each of them used to need. Two of those are worked through on this page: the bundled [`open77_fuel`](#the-fuel-sample-open77_fuel) resource is the reference for a server-owned per-vehicle value with a consumption tick, and the [plate recipe](#recipe-a-number-plate) is the two-line version. The FiveM names work too, because ported code reaches for those and never for an `Open77.*` name: ```lua Player(source).state.cuffed = true Entity(vehicleId).state:set("keys", { "12", "13" }) GlobalState.hour = 21 LocalPlayer.state.job -- client only AddStateBagChangeHandler("cuffed", nil, function(bagName, key, value) ... end) ``` --- ## `Open77.state` now holds two unrelated things This is worth stating plainly before anything else, because it will look like a mistake otherwise. | Name | What it is | |---|---| | `Open77.state.save` / `.load` / `.clear` | **Resource-private JSON that survives a reload and never crosses the wire.** One opaque blob per resource, 64 KiB, dropped on stop. See [server resources](server-resources.md). | | `Open77.state.global` / `.player(id)` / `.entity(...)` / `.onChange` / `.allowRequest` | **Replicated key/value bags**, the subject of this page. | They share a table and share nothing else. The tidier arrangement would have been to move the first group somewhere like `Open77.resource.stash`, and that was considered and rejected: five of the seven bundled server resources feature-detect with ```lua if type(Open77.state) ~= "table" or type(Open77.state.save) ~= "function" then return end ``` and a rename that left those guards standing would make them take the silent no-op branch. Cordon would stop carrying its match state across a reload and say nothing about it. A silent data loss is worse than a slightly crowded table, so `save`/`load`/`clear` keep working exactly as they always have, and the bag accessors were added beside them. There is no parity cost either way: nothing ported from FiveM reaches for an `Open77.*` bag name. --- ## Selectors A **selector** names one bag. Every one of these returns a bag handle. | Selector | Side | Meaning | |---|---|---| | `Open77.state.global` | both | The one server-wide bag. Not a function: it is the handle. | | `Open77.state.player(id)` | both | The bag of one player. `id` may be a number or its decimal string. | | `Open77.state.entity(kind, id)` | both | `kind` is `"vehicle"`, `"npc"` or `"prop"`. | | `Open77.state.entity{ kind = , id = }` | both | The same thing in table form. | | `Open77.state.localPlayer()` | client | The local player's own bag. `nil, "no_session"` before a session exists. | A handle is a plain Lua table behind a protected metatable, so it can be kept in an upvalue and reused; asking for the same bag twice gives you an equivalent handle either way. ### Reading ```lua local bag = Open77.state.player(source) bag:get("job") --> value, or nil bag.job --> the same value (read sugar) bag:all() --> { job = ..., duty = ... } -- a copy, never a live view bag:revision() --> a monotonic integer, 0 for a bag that does not exist bag:selector() --> { kind = "player", id = "12" } ``` `bag:get` and `bag.key` are the same store. A bag that does not exist reads as an empty one rather than erroring, which is what lets a client read a key before the server has ever written it. **Five key names are shadowed by the methods**: `get`, `set`, `all`, `clear` and `revision`. `bag.get` is the function, not the value under the key `"get"`. Reach those keys — if you really must have them — with `bag:get("get")`. ### Writing (server only) ```lua bag:set("fuel", 61.5) --> true | false, reason bag:set("fuel", nil) --> clears the key bag.fuel = 61.5 --> write sugar; RAISES on refusal bag:clear("fuel") --> true | false, reason bag:clear() --> destroys the whole bag ``` Writing needs the `state.write` permission in the manifest. Reading never does. ```lua -- open77.lua permission "state.write" ``` `bag:set` returns `false, reason` so a caller can handle a refusal. The `bag.key = value` sugar has no return channel, so it **raises**: a write that silently disappeared would be far harder to find than one that says so at the line that is wrong. ### Values Anything JSON can carry: strings, numbers, booleans, and nested tables of them. Functions, coroutines and cycles are refused with `state_value_not_serializable`. A **vector goes in and comes back as the plain `x`/`y`/`z` table** every Open77 position API already returns — that is the [vector](vectors.md) contract, not a limitation of bags: ```lua bag:set("spawn", vector3(-1234.5, 678.25, 12.5)) local spawn = bag.spawn spawn.x --> -1234.5 vector3(spawn) --> a vector again, for the maths ``` --- ## Change handlers ```lua local subscription = Open77.state.onChange(selector, key, function(selector, key, value, previous) print(selector.kind, selector.id, key, value, previous) end) Open77.state.offChange(subscription) ``` `selector` may be a bag handle, a `{ kind = , id = }` table, a bare kind string (`"player"`, `"vehicle"`, …) to watch every bag of that kind, or `nil`/`"*"` for every bag. `key` may be a key name or `nil`/`"*"` for every key. The handler runs at a tick boundary, never inside the write, so it may `Wait`. On a cleared key `value` is `nil`; on a first write `previous` is `nil`. **Only the resources that subscribed are touched.** The filter lives on the host side, so a `duty` flip does not marshal a payload into every running resource so that one of them can read it. ### The FiveM shim ```lua local id = AddStateBagChangeHandler(key, bagName, function(bagName, key, value, reserved, replicated) ... end) RemoveStateBagChangeHandler(id) ``` `bagName` is `nil` for every bag, `"global"`, `"player:"`, `":"`, or `"entity:"` — the last matches any entity kind with that id, because FiveM has one entity namespace and Open77 has three registries. `reserved` is always `nil` and `replicated` is always `true`; both exist so a ported signature still lines up. --- ## The client never writes a bag This is the rule that surprises people porting from FiveM, and it is deliberate. `bag:set` exists on the client, and always answers: ```lua local ok, reason = Open77.state.global:set("weather", "sun") --> false, "requires_server_arbitration" ``` `bag.key = value` on the client raises, for the same reason as on the server: a silent no-op would be worse. What a client *can* do is **ask**: ```lua -- client Open77.state.request(Open77.state.localPlayer(), "seatbelt", true) --> true | false, reason ``` `request` acknowledges the **send**, never the outcome. The server decides, and the value lands in the mirror when the delta comes back — there is no optimistic apply, and no path by which a client's bytes become the bag's value unread. The server opts a key in and supplies the validator: ```lua -- server Open77.state.allowRequest("player", "seatbelt", function(source, value) if type(value) ~= "boolean" then return false end return true, value -- accepted, and this is the value that gets stored end) Open77.state.denyRequest("player", "seatbelt") ``` The validator runs in the declaring resource's VM, and **the value it returns is what the bag stores** — return a normalised value to clamp, round or rewrite whatever the client sent. Returning anything but `true` refuses. A key nobody opted in is refused as `not_requestable` before any Lua runs at all. `allowRequest` accepts a kind string to cover every bag of that kind, or a specific bag handle / `{ kind = , id = }` to cover one. ### Why, and the cost of getting it wrong Two reasons, and the second is the hard one. **Trust.** A bag is shared state. A client that could write `Player(src).state.job` could write it for anybody watching, and an admin flag, a bounty or a stash code is exactly the kind of thing a bag carries. **The inbound budget.** The transport disconnects a session on its **33rd inbound net event in one second**. A FiveM resource that sets a client state key every frame will, ported naively, drop the player. Open77 makes that hard to do by accident: - the client shim **coalesces** — every `request` in a window is collapsed per key and sent as one net event, at most four times a second; - the server refuses beyond **4 requests per second per session**, with `state_request_rate_limit`, long before the transport's limit can fire. A refusal comes back as a log line on the client rather than vanishing. Do not build a UI that calls `request` on every keystroke; call it when the value settles. --- ## Who sees what | Bag | Audience | |---|---| | `global` | every connected player, every routing bucket | | `player(id)` | every player in that player's routing bucket, including the subject | | `entity(kind, id)` | exactly the players streaming that entity — the registry's own viewer set | An entity bag's audience **is** the entity's audience, which is what makes stream-in, stream-out and bucket changes correct without a second interest pass to keep in sync. **Routing buckets are isolated.** A player in another bucket does not receive the value and is not told to ignore it: a bag carries gameplay secrets, so shipping it to a client that is merely asked not to look is an information leak with no bandwidth saving. Moving bucket drops the player's whole mirror and re-sends what the new bucket holds, atomically with the move. A late joiner, a streamed-in entity and a resynchronising client all receive a **full snapshot**, chunked across several events when it is large. Everything else is a **delta** carrying only the keys that moved. --- ## Lifecycle - A bag **dies with its subject**: removing a prop, vehicle or NPC destroys its bag, and a disconnecting player takes theirs with them. Every holder is told to drop it, and every key raises a change so a handler watching one key sees it go. - A resource **stopping** retires its change subscriptions and its `allowRequest` validators. A requestable key whose validator went down with its resource stops being requestable, rather than accepting whatever the client sends. - The client mirror lives in the host, not in a resource, so it **survives a resource restart**: a resource coming back up reads the current values immediately. - A new server session drops the mirror and asks for a fresh one, so the previous server's bags never linger. --- ## Caps Nothing truncates. A write that breaches a cap is refused by name and the bag is left exactly as it was — no partial apply, no key dropped to make room. | Cap | Value | Refusal | |---|---|---| | Keys per bag | 64 | `bag_key_limit` | | Key name | 64 bytes, `[A-Za-z][A-Za-z0-9_.-]*` | `invalid_state_key` | | Value, serialized | 4 KiB | `state_value_too_large` | | Value depth / nodes | 8 / 256 | `state_value_not_serializable` | | Bytes per bag | 16 KiB | `bag_byte_limit` | | Bags per server | 8 192 | `bag_limit` | | Total bag bytes | 8 MiB | `bag_byte_limit` | | Client requests | 4 per second per session | `state_request_rate_limit` | | Server writes | 20 000 per second per resource | `state_write_rate_limit` | The value budget is deliberately tighter than the 48 KiB export envelope: an export payload crosses once, a bag value is re-sent to every viewer on every change and again in every snapshot. The server write budget is a safety valve, not a bandwidth control — ten writes to one key in one tick already collapse into one delta carrying the final value, so outbound cost follows the number of distinct keys that *moved*. A resource that trips it is named in the server log, once per window, so the failure is attributable. ### Every failure reason | Reason | Means | |---|---| | `invalid_state_key` | The key name is empty, too long, or outside `[A-Za-z][A-Za-z0-9_.-]*`. | | `invalid_state_selector` | The kind is not one of `global`/`player`/`vehicle`/`npc`/`prop`, or the id is not a non-negative integer. | | `state_value_too_large` | Over 4 KiB serialized. | | `state_value_not_serializable` | A function, a coroutine, a cycle, or deeper than 8 / more than 256 nodes. | | `bag_key_limit` | A 65th key. | | `bag_byte_limit` | Over 16 KiB in one bag, or 8 MiB across the server. | | `bag_limit` | An 8 193rd bag. | | `unknown_bag` | Clearing a key or a bag that does not exist. | | `permission_denied:state.write` | Writing without the manifest permission. | | `requires_server_arbitration` | `set`/`clear` called on the client. | | `not_requestable` | A client asked for a key no resource opted in. | | `state_request_rate_limit` | More than 4 client requests a second from one session. | | `state_write_rate_limit` | More than 20 000 server writes a second from one resource. | | `state_unavailable` | The host has no bag registry (a fixture that does not replicate). | --- ## The fuel sample (`open77_fuel`) Cyberpunk has no fuel model, so FiveM's `GetVehicleFuelLevel` / `SetVehicleFuelLevel` have no native to adapt. Fuel is a bag field, and [`resources/system/open77_fuel`](https://github.com/Open2077/open77-resources/tree/main/system/open77_fuel) is the canonical sample of the pattern -- small enough to copy, and shipped so the three places that already cited it were telling the truth. **What it does.** The server owns one number, `fuel`, on every canonical vehicle's bag. Once a second it walks `Open77.vehicles.all()`, reads the replicated `speed` and the `engineOn` bit the physics owner already reports, and burns the tank through a pure model (`shared/fuel.lua`: litres per hundred kilometres while moving, litres per minute while idling, one `multiplier` over both). At zero it cuts the engine with `Open77.vehicles.setEngine(id, false)` -- the server-authored bit that is held against the owner's next report -- and keeps cutting it every tick until somebody refuels, and it fires `open77:fuel:empty(vehicleId)` host-wide once per empty. A tank the model has never seen starts full; a removed vehicle takes its bag with it. ```lua -- Any server resource: FiveM's two natives, as exports. local litres = exports.open77_fuel:level(vehicleId) -- GetVehicleFuelLevel exports.open77_fuel:set(vehicleId, 12.5) -- SetVehicleFuelLevel exports.open77_fuel:refuel(vehicleId) -- to the brim; refuel(id, 20) adds 20 L exports.open77_fuel:configure({ litresPerHundredKm = 9.0 }) -- or multiplier, idleLitresPerMinute, capacity -- Or the bag itself, with no export in the way. local litres = Open77.state.entity("vehicle", vehicleId).fuel Entity(vehicleId).state:set("fuel", 30) -- the FiveM spelling; needs state.write ``` ```lua -- Any client: a gauge is a read. No permission, no event, no request. local litres = Open77.state.entity("vehicle", vehicleId).fuel local mine = exports.open77_fuel:current() -- the car the local player is sitting in AddEventHandler("open77:fuel:changed", function(vehicleId, litres, previous) gauge.set(litres) end) ``` **What it deliberately is not.** The client never writes the bag, because a fuel gauge a client could set is a fuel gauge a client can cheat; consumption is derived from the owner's replicated speed, which the server already validates. And the model lives in a shared file with its own Lua suite (`tests/fuel_test.lua`, run inside the server test process) so the numbers a server owner tunes are the numbers that are tested. Commands: `/fuel ` prints the tank, `/refuel [litres]`, `/fuel.set ` and `/fuel.multiplier ` are admin-gated. The manifest holds `world.vehicles` and `state.write` on the server and `vehicles.read` on the client, the last only so `current()` can map the entity the local body is mounted in back to its canonical id. ## Recipe: a number plate Cyberpunk has no readable or writable plate -- `Open77.vehicles.setProperties(id, { plateText = ... })` is refused by name and `Open77.vehicles.unsupportedProperties.plateText` says why -- so a plate is a bag key, exactly like fuel but without the tick: ```lua -- server (needs state.write): the plate is whatever the server says it is Entity(vehicleId).state:set("plate", "NC-1977") -- the same write, natively spelled Open77.state.entity("vehicle", vehicleId):set("plate", "NC-1977") ``` ```lua -- either side: read it back local plate = Entity(vehicleId).state.plate ``` There is no `Open77.vehicles.setLabel`, and nameplates are player-keyed, so the plate is **shown** the way any other per-entity text is: a client-side world anchor (`Open77.anchors.create` in the [API reference](index.html), with `render = "dot"` drawn natively by the plugin) pinned to the car's entity, whose label follows the bag. ```lua -- client: a small dot above every streamed car that carries a plate local anchors = {} local function show(vehicleId, plate) local car = Open77.vehicles.get(vehicleId) -- needs vehicles.read if car == nil or not car.streamed then return end local presentation = { label = plate, scale = 0.8 } if anchors[vehicleId] then Open77.anchors.update(anchors[vehicleId], { presentation = presentation }) return end anchors[vehicleId] = Open77.anchors.create({ render = "dot", entity = car.entity, offset = { x = 0, y = 0, z = 1.6 }, maxDistance = 25, presentation = presentation, }) end Open77.state.onChange("vehicle", "plate", function(selector, _, plate) local vehicleId = tonumber(selector.id) if plate == nil then if anchors[vehicleId] then Open77.anchors.remove(anchors[vehicleId]) end anchors[vehicleId] = nil else show(vehicleId, plate) end end) AddEventHandler("open77:vehicleRemoved", function(vehicleId) if anchors[vehicleId] then Open77.anchors.remove(anchors[vehicleId]) end anchors[vehicleId] = nil end) ``` The bag change arrives before the car has necessarily streamed in on this client; the `open77:vehicleCreated` event is the moment to call `show` for a plate that was set before the car was in range. Everything else -- late joiners, bucket changes, the car leaving -- the bag and the anchor already handle: the bag is re-sent with the entity, and an entity anchor whose entity is not streamed is simply not drawn. ## Cost Five things keep this cheap, in the order they matter: deltas rather than snapshots, a dirty set rather than a scan, coalescing to the tick, recipients from the entity's own viewer set, and **one event per recipient per tick** carrying everything that recipient is owed. Measured at 64 players, each with a 20-key bag: | Tick | Events | Bytes server-wide | Time | |---|---|---|---| | 5 keys change (a realistic roleplay tick) | 64 | ~16 KB | ~0.5 ms | | All 64 bags change at once (pathological) | 64 | ~213 KB, largest event 3.3 KB | ~20 ms | Never 64 × 64 messages, and every event fits one net-event envelope with room to spare. The second row is reachable only if every player in one bucket changes a key in the same tick; most of its cost is the transport encoding 213 KB, not the bags. --- ## The wire, and why there is no protocol change Bags ride the existing `NetEvent` envelope, the same road props, effects and loot already take. That is deliberate and load-bearing: the protocol version check is **exact equality on every frame, in both directions**, so a new binary opcode would cost a `MinorVersion` bump and refuse every client that had not been rebuilt in lockstep, at the first frame. The five reserved names — `open77:state:snapshot`, `:delta`, `:drop`, `:ready` and `:request` — are platform-owned on both sides. A resource cannot publish one and a client cannot raise one into a resource handler. --- ## See also - [Vectors and quaternions](vectors.md) — what a vector is when it crosses the network. - [Server resources](server-resources.md) — manifests, permissions, and `Open77.state.save`. - [Complete server Lua API](server-api.md) — every server global and method. --- Source: https://open2077.net/docs/readiness-gate # The join-time readiness gate Wait for a player's readiness gate before teleporting, spawning, killing or forcing a respawn. Resources can hold the gate while character creation, rules acceptance or another join-time flow is active. > **The rule: do not teleport, spawn, kill or force a respawn on a player until > their readiness gate has opened.** Reading their state, putting them on a > roster, sending them a HUD payload — all unaffected. `Open77.ready` is the platform's answer. It is **server-side only**, and it has to live in the host so holds from every resource and generation form one barrier. Resources can communicate through [server exports](server-exports.md), but those calls do not replace readiness aggregation or its reload/timeout cleanup. `TriggerEvent` still walks only its own VM. No permission is required. ## Why this exists This is not a hypothetical. On a live server, enabling the database for a ranked ladder also switched on persistence for the appearance package — because [the database bridge is per-server, not per-resource](resource-runtime.md#the-database-bridge-is-per-server-not-per-resource). The appearance package began opening a character creator on join, while the gamemode teleported the same players to its lobby. Each was individually correct; the server was unusable. ## The API | Function | Signature | Result | |---|---|---| | `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 then 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 one of `cleared`, `no_holds`, `resource_reloaded`, `resource_stopped`, or `timeout:`. ## Waiting on the gate Keep your own join signal and ask the gate for permission. Two handlers: ```lua RegisterNetEvent("mymode:ready", function() -- your client's "my world is up" local playerId = source local record = ensurePlayer(playerId) if not Open77.ready.isReady(playerId) then record.awaitingReady = true return end place(playerId, record) end) AddEventHandler("onPlayerReady", function(playerIdStr, detail) local playerId = tonumber(playerIdStr) -- host events carry strings local record = players[playerId] if record == nil or record.awaitingReady ~= true then return end record.awaitingReady = nil place(playerId, record) end) ``` Three things about that shape are load-bearing. **`onPlayerReady` is a barrier lifting, not a trigger.** It says only that nobody is holding this player any more; it says nothing about whether their world is up. That is why the wait starts from *your* event and never from `onPlayerReady` alone. **A gate can open on a timeout**, with the player still in a modal and no puppet at all — `detail` will say `timeout:`. Placing an unincarnated player crashes their client, so check `Open77.players.getLifeState(playerId)` and, if it is `nil`, leave your flag set and let their next announce do it. **It costs a returning player nothing.** A player who already has a character is released as soon as the holding resource has looked them up, so the check simply passes and the placement is as immediate as it was before. A server where nothing participates is a server where the gate is always open, so a resource that never consults it behaves exactly as it did before this existed. ## Holding the gate If *your* resource is the one that needs the player — a rules acceptance, a class pick, a spawn choice — declare yourself once, at load: ```lua Open77.ready.participate({ timeoutMs = 30000, reason = "character_creation" }) ``` Every player who connects from then on arrives with one hold in your name, so you are not racing to take it — you already have it. You then answer: ```lua -- Capture the session BEFORE any await. local session = Open77.ready.status(playerId).session -- Nothing to ask of this player: the common case, and it must stay instant. Open77.ready.release(playerId, session) ``` ```lua -- Or: I do need them, with a reason an operator will read, and a fresh deadline. local session, failure = Open77.ready.hold(playerId, "character_creation") ``` `timeoutMs` is the budget for **answering**, not for the interaction. `hold` refreshes the deadline, so heartbeat it while the thing is genuinely in flight: a player taking four minutes over their face is never cut off, while a resource that dies, a rejected commit or an abandoned UI all release within one timeout of the last sign of life. **Capture the `session` before any database await and pass it back afterwards.** Player ids are recycled. The session number is what stops an answer computed for someone who has since disconnected from opening the gate of whoever inherited their id. Omit it and the release applies to whoever holds that id now. The optional `note` on `release` becomes the `detail` every resource sees on `onPlayerReady` — which is how one resource can tell the others something useful without a channel to speak on. ## What cannot happen | Situation | What the platform does | |---|---| | A resource takes a hold and never releases | The hold expires, the gate opens with `timeout:`, and one `WRN` names the resource that failed | | A participant is reloaded mid-hold | Its holds are **released**, never inherited — the fresh VM has no memory of the player, and re-arming would deadlock | | A participant is stopped | Declaration and holds both go; nothing gates joiners on a resource that is not there | | A player reconnects onto a recycled id | The session advances; a release stamped with the old one is dropped | | A gate has already opened | It stays open for that session — `hold` answers `already_ready` rather than re-closing it | Every hold carries a deadline, so a resource that never releases costs one degraded join and one warning naming it — never a stuck server. ## Seeing who is holding whom "Nothing happens when I join" is the symptom this produces when it goes wrong, and it is invisible from every other angle: the player is connected, the world is up, and every resource is correctly waiting. At the server console: ```text > ready participants: open77_appearance player 11 (session 3) held by open77_appearance [character_creation] ``` ## See also - [Writing a gamemode](writing-a-gamemode.md) — where this sits in a join path. - [Operator tunables](tunables.md) — the other thing a server owner changes live. - [The Lua resource runtime](resource-runtime.md) — the isolation model that makes a host-owned barrier the only possible design. --- Source: https://open2077.net/docs/client-kvp # Client persistent KVP `Open77.kvp` persists small values on the client. Data is isolated by connection address, resource name and key: ```text connection address -> resource name -> key ``` Connecting to `188.40.140.88:11777` and `localhost:11777` therefore creates two independent stores, even if both addresses reach the same machine. Resources cannot name or inspect another resource's namespace. ```lua local ok, reason = Open77.kvp.set("character:lastSlot", 2) assert(ok, reason) local slot = Open77.kvp.get("character:lastSlot", 1) local present = Open77.kvp.has("character:lastSlot") local newCount = Open77.kvp.increment("stats:connections", 1) ``` ## API | Method | Result | Description | |---|---|---| | `set(key, value)` | `true` or `false, reason` | Store a string, integer, finite number, or boolean. | | `get(key[, default])` | value/default, or `nil, reason` | Read the original Lua type. Missing keys are not errors. | | `has(key)` | boolean, optionally `reason` | Check whether the key exists. | | `delete(key)` | boolean, optionally `reason` | Delete a key and report whether it existed. | | `keys([prefix[, limit]])` | string array or `nil, reason` | Sorted prefix search; default limit is 256. | | `find([prefix[, limit]])` | entry array or `nil, reason` | Return `{ key, value, type }` records. | | `clear([prefix])` | removed count or `nil, reason` | Delete this resource's matching keys only. | | `increment(key[, delta])` | number or `nil, reason` | Atomically create/increment a numeric value. | | `setIfAbsent(key, value)` | boolean, optionally `reason` | Redis-style `SETNX`. | | `compareAndSet(key, expected, replacement)` | boolean, optionally `reason` | Atomic CAS. `nil` means missing for expected and deletion for replacement. | | `stats()` | table or `nil, reason` | Entry/byte usage, quotas, address, and resource namespace. | FiveM-familiar aliases are also available: `SetResourceKvp`, `GetResourceKvp`, and `DeleteResourceKvp`. New code should prefer `Open77.kvp` because it exposes typed results and the atomic/search operations. ## Limits and persistence guarantees - Keys: 1-256 bytes, with no control characters. - String values: at most 64 KiB each. - Resource store: at most 4,096 entries and 1 MiB of key/value payload. - Files are committed through a same-directory temporary file and an atomic replace. - A versioned binary header and CRC32 reject truncated or corrupted files instead of silently replacing them. - Validation/preflight Lua states cannot write KVP data. Data is stored below `red4ext/plugins/Open77/storage/kvp`. The connection address is reversibly hex-encoded before becoming a directory name, preventing path traversal without changing its namespace semantics. This is local persistence, not secret storage. The player owns the machine and can inspect or remove the files. Never store passwords, server tokens, private keys, or authoritative economy state in client KVP. Use the server database for anything that must resist client modification. ## The server twin `Open77.kvp` now exists on the server too, with the same eleven functions, the same quotas and the same reason tokens — see [Resource key/value store](server-api.md#resource-keyvalue-store). The two stores are entirely separate: the client's lives on the player's machine and is keyed by server address plus resource, the server's lives in one JSON file under that resource's `data/` directory on the host. A shared script calling `Open77.kvp.set` therefore writes to whichever side it is running on, which is almost always what you want and never what you should assume — anything the server must be able to trust belongs in the server store or the database, not in the client's. --- Source: https://open2077.net/docs/prediction # Prediction Prediction makes an action feel instant for the player who performs it. When you hit someone, open a door or crash into a car, **your own screen shows the result straight away** instead of waiting a round trip for the server. The server still decides: it **confirms** the action, and the real reaction takes over from the predicted one without a visible change, or it **refuses** it, and the prediction is **rolled back smoothly**: a knocked-down body gets back up, a pushed car eases back to where it really is, a door closes again. A prediction only changes what the acting player sees before the verdict. It never deals damage, never moves anything on other players' screens and never grants authority: every outcome is still the server's. Client and server must run the same Open77 build. ## What is built in and what the resources add | Layer | What it provides | What it needs | |---|---|---| | **Open77 client** (native) | Car against car (`carContact`), car against a network NPC opted in with [`behavior.vehicleContactEnabled`](npc-behavior.md#vehicle-contact-damage) (`carContact`), car against player (`playerContact`), door (`door`, on the network doors of the bundled [`open77_doors`](https://github.com/Open2077/open77-resources/tree/main/system/open77_doors) service) and blast (`blast`) prediction. | Nothing: always on. Compiled defaults: every family on, and no new prediction starts above a 250 ms round trip. | | **`open77_cyberware`** (bundled system resource, already running) | Melee (`melee`), Ground Slam (`slam`) and quickhack (`hack`) knockdown prediction. | The gamemode's combat policy, published with the local client event [`open77_prediction:policy`](#melee-ground-slam-and-quickhack-predictions); Freeroam publishes one. Without it these three never predict. | | **`open77_prediction`** (bundled platform resource, starts automatically) | Only the operator layer: the per-family switches, the round-trip ceiling, the [`open77.prediction` state bag key](#the-published-policy), [telemetry](#operator-controls), the `prediction` admin command and the [`restrict` export](#gamemode-restrictions) for gamemodes. | Nothing depends on it. Without it, clients keep their compiled defaults and the server has no switches and no telemetry. | ## Do I need open77_prediction? No, it is optional `open77_prediction` is an **extra**. Prediction works without it, for three reasons: 1. **The predictions are native client code, not a resource.** Showing the result early, adopting it when the server confirms and rolling it back when the server refuses all happen in the Open77 client. 2. **The client ships safe defaults.** Without the resource, every family is on and no new prediction starts above a 250 ms round trip. 3. **The server still decides everything that counts.** Every hit, damage value, push and death is the server's call, so the game stays correct and fair with or without the resource. What you lose without it is only **control and visibility**: the per-family switches, the ping ceiling setting, gamemode `restrict`, telemetry and the `prediction` admin command. Keep it when you want to turn a family off, tune the ceiling or watch refutation rates. It is loaded by default on every server. It only goes missing when a [`resources.load` allowlist](#server-setup) omits it. Melee, Ground Slam and quickhack prediction need the gamemode's [combat policy](#melee-ground-slam-and-quickhack-predictions) either way; that requirement is independent of this resource. ## Prediction families Each kind of prediction is a **family** with its own switch. | Family | What the acting player sees early | |---|---| | `melee` | A melee hit knocks the victim down on the attacker's screen before the server's verdict. | | `slam` | A Ground Slam knocks nearby players down on the attacker's screen before the server's verdict. | | `hack` | A knockdown quickhack drops its target on the hacker's screen before the server's verdict. | | `door` | A network door starts opening as soon as it is used, before the server accepts the request. | | `blast` | Cars and players another client simulates react to the shooter's own blast before its relay returns. | | `carContact` | A remote car struck by the local driver starts moving at once instead of about 250 ms later; an opted-in network NPC the driver hits starts falling at once. | | `playerContact` | A player hit by the local driver starts falling at once, before the victim's fall cue returns. | Every prediction ends in one of three ways, which is what the counters report: - **adopted**: the server's own reaction took it over or confirmed it; - **refuted**: the server refused it, or did not answer in time, and it was rolled back; - **skipped**: the policy stopped it before it started, because its family is switched off (`skippedDisabled`) or the round trip is above the latency ceiling (`skippedLatency`). A late verdict makes a refusal far more visible, so a client whose measured round trip is above the **latency ceiling** (250 ms by default) starts no new prediction; one already playing finishes normally. An unknown round trip, before the first measurement, does not block anything. ## API at a glance **Prediction** - [`Open77.prediction.stats`](/docs/api/client/open77-prediction#stats): the live prediction policy, this client's round trip and each family's counters. - [`Open77.prediction.setPolicy`](/docs/api/client/open77-prediction#setpolicy): the switches and latency ceiling this client applies. Needs the dedicated `prediction.policy` permission; the bundled `open77_prediction` resource is its only intended caller. See [Advanced](#advanced-native-calls). - [`Open77.motion.predictAction`](/docs/api/client/open77-motion#predictaction), [`refutePrediction`](/docs/api/client/open77-motion#refuteprediction) and [`predictionStats`](/docs/api/client/open77-motion#predictionstats), **experimental**: start, roll back and count the melee, Ground Slam and quickhack knockdown predictions. - `open77_prediction`: the [`prediction` admin command](#operator-controls), the gamemode export [`restrict`](#gamemode-restrictions) and the seven [prediction families](#prediction-families), published to every client through the [`open77.prediction` state bag key](#the-published-policy). - [`resources.load` allowlists](#server-setup) must list `open77_prediction`, or the server has no switches and no telemetry. **NPCs** - [`Open77.npcs.presence`](/docs/api/server/open77-npcs#presence): where an NPC is and which client simulates it, in one constant-time read. See [NPC spawning](npcs.md#server-api-reference). - [`behavior.vehicleContactEnabled`](npc-behavior.md#vehicle-contact-damage): opts an NPC in to server-priced damage when a car hits it. - [`behavior.hitPricing`](npc-behavior.md#who-prices-player-hits-hitpricing): `"platform"` lets the server price player hits on a mortal NPC instead of the resource that created it. - [`moveTo` end reasons](npcs.md#move-follow-and-patrol) `path_blocked`, `nearest_reachable` and `report_jump`: a walk never arrives by teleport, and the task says why it stopped. **Doors** - [`setNpcPassage`](doors.md#npc-passage) and `configure(..., { npcPassage = ... })` with the modes `public`, `resource`, `always` and `never`: which network NPCs may open a door. - [`npcStats`](doors.md#npc-passage): NPC door openings admitted and refused. - [`resolveAction(ticket, false)`](doors.md#door-actions-force-pay-and-hack) answers `false, "refused_by_owner"`: `true` only ever means the door opened. **Weapons and explosions** - [`Open77.weapons.applyBlast`](/docs/api/client/open77-weapons#applyblast): pushes the cars this client simulates and knocks down its NPC copies for a server-relayed blast. - [`relayBlast`](weapons-api.md#explosions-on-every-screen-the-blast-relay): relays a blast another server resource admitted, so every screen sees cars move and NPCs fall. - [`open77_blasts`](weapons-api.md#explosions-on-every-screen-the-blast-relay) convar: `off` disables the blast relay. - [`onPlayerExplosion`](weapons-api.md#gadgets-grenades-in-the-quick-slots): the server event for a detonation a player's client observed. - [`onVehicleWeaponExplosion`](vehicle-weapons.md#current-damage-policy): the server event for a mounted weapon's admitted explosion. - [`onConsumableUsed`](consumables-api.md#observe-actual-uses-and-apply-healing-policy): a reserved host event that also credits grenade charges to the blast relay. **Server configuration** - [`combat.handheldDischarges`](npc-behavior.md#server-configuration) (default `true`): the server admits each handheld shot, which platform-priced NPC hits require. - [`combat.handheldVehicleDamage`](vehicle-weapons.md#handheld-guns-on-cars-another-player-drives) (default `false`): the server applies handheld gun hits on cars another player drives. ## Server setup `open77_prediction` is a **platform resource**: it ships with every Open77 server and starts automatically, so its code is not in your `resources` folder. Do not copy it into your resources; the bundled one is the one that runs. Its source is published for reference only, read-only: [open77_prediction on GitHub](https://github.com/Open2077/open77-resources/tree/main/system/open77_prediction). **If `server.jsonc` selects resources with a `resources.load` allowlist, add it** to keep the switches, the admin command and the telemetry. A resource the list does not select is never started, and nothing is logged about it: ```jsonc "resources": { "load": [ "open77_shell", "open77_chat", "open77_prediction", // add this line to an exact list "freeroam" ] } ``` A wildcard rule such as `"open77_*"` already selects it. `load` is read at startup, so restart the server after editing it. See [Selecting which resources load](server-resources.md#selecting-which-resources-load). Client and server must run the same Open77 build: the native prediction code ships in the client and the verdicts come from the server. ## Operator controls The seven family switches, the latency ceiling and the telemetry interval are [operator tunables](tunables.md) of `open77_prediction`. Change them in Warden, with `tunable.set open77_prediction ` at the console, or with the restricted `prediction` command (ACL `command.prediction`): ```text prediction status: policy, gamemode restrictions, telemetry totals prediction off blast turn one family off (melee, slam, hack, door, blast, carContact, playerContact) prediction on blast turn it back on prediction ping 200 latency ceiling in milliseconds, 0 to 5000; 0 removes it prediction telemetry 120 report interval in seconds, 0 to 3600; 0 turns reports off ``` | Tunable | Default | Meaning | |---|---|---| | `melee`, `slam`, `hack`, `door`, `blast`, `carContact`, `playerContact` | `true` | Off: this family never predicts; the server's reaction is unchanged. | | `maxPingMs` | `250` | Above this round trip a client starts no new prediction. `0` removes the ceiling. | | `telemetrySeconds` | `60` | How often each client reports its counters. `0` turns reports off; below 15 s counts as 15 s. | Changes are saved and reach every client at once, late joiners included; they apply to the next prediction. **Telemetry.** Each client reports only the counters that changed, once per interval, and nothing when nothing happened. The server logs one line per window: ```text telemetry 60s clients=12 reports=12 dropped=0 | melee p=40 a=37 r=3 off=0 lag=2 rr=8% | ... ``` `p`, `a` and `r` are predicted, adopted and refuted; `off` and `lag` are the two kinds of skip; `rr` is refuted / (adopted + refuted). Up to five `high refutation player= ping= ...` lines follow for a family with at least five verdicts of which half or more were refuted. Telemetry is informational only and never an authority input. The resource's server exports `policy()` (the effective policy) and `telemetry()` (cumulative totals and the last window) return the same data as tables. ## Gamemode restrictions A gamemode can only **narrow** the operator's policy, for as long as it runs: turn families off and lower the ceiling (1–5000 ms). It cannot turn on a family the operator turned off. ```lua -- Server script of a gamemode: no blast or car-contact prediction, and a lower ceiling. local function restrictPrediction() local promise, reason = Open77.exports.call("open77_prediction", "restrict", { families = { blast = false, carContact = false }, maxPingMs = 150, }) if not promise then print("open77_prediction unavailable: " .. tostring(reason)); return end local ok, failure = promise:await() if not ok then print("prediction restriction refused: " .. tostring(failure)) end end AddEventHandler("onResourceStart", function(name) if name == GetCurrentResourceName() then CreateThread(restrictPrediction) end end) -- Restrictions do not survive a stop or restart of open77_prediction; it announces itself again. AddEventHandler("open77_prediction:ready", function() CreateThread(restrictPrediction) end) ``` A restriction is keyed by the calling resource and replaced by its next call. `restrict(nil)`, `clearRestriction()` or the caller's stop withdraws it. It survives a reload of `open77_prediction`. The effective policy is the operator's switches with every running restriction applied, and the lowest ceiling wins. Refusals: `unknown_caller`, `invalid_restriction`, `invalid_prediction_family`, `invalid_ping_ceiling` and `restriction_limit` (32 restricting resources). ## The published policy The server publishes the effective policy in the global [state bag](state-bags.md) under the key `open77.prediction`: `{ v = 1, families = { melee = true, ... }, maxPingMs, telemetrySeconds }`. Every client applies it at once, late joiners included. The key is cleared when `open77_prediction` stops, and clients fall back to their defaults. ```lua -- Client: read the policy the server publishes, and follow its changes. local policy = Open77.state.global:get("open77.prediction") if policy then print("blast prediction", policy.families.blast, "ceiling", policy.maxPingMs) end Open77.state.onChange(Open77.state.global, "open77.prediction", function(_, _, value) print("prediction policy changed; ceiling", value and value.maxPingMs) end) ``` ## Melee, Ground Slam and quickhack predictions The three action families are started by the bundled [`open77_cyberware`](https://github.com/Open2077/open77-resources/tree/main/system/open77_cyberware) resource, and only where the gamemode has published its combat policy. Without that policy they **fail closed**: they never predict. This is deliberate. Only the gamemode knows whether a hit can land at all: its PvP switch, the bucket it plays in, its safe zones and its damage multipliers. A client guessing without them would predict knockdowns the server then refuses, and the attacker would see the victim fall and get back up. The client must not predict a hit the server will refuse. The gamemode publishes the policy from a client script with the local event `open77_prediction:policy`: sent when the gamemode starts, sent again on `open77_prediction:requestPolicy`, and withdrawn with `{ enabled = false }` when it stops. Nothing goes over the network and no permission is needed. Freeroam does this for the street bucket and its spawn safe zones. A complete example resource is published for reference; copy its `client/main.lua` into your gamemode and fill the values from your own configuration: [prediction_policy_example on GitHub](https://github.com/Open2077/open77-resources/tree/main/rp/prediction_policy_example). | Field | Meaning | |---|---| | `owner` | Your resource name. The policy is dropped when that resource stops. | | `enabled` | `true` when PvP is on. `false` or a missing policy means no melee, Slam or hack prediction. | | `bucket` | The routing bucket these rules cover. Players in other buckets are not predicted. | | `safeZoneRadius`, `safeZones` | No prediction when the attacker or the victim is inside one of these spheres. | | `damageMultiplier`, `meleeMultiplier`, `explosionMultiplier`, `headshotMultiplier` | Your damage scales. A contact that might be lethal after them is not predicted. | ```lua -- Client script of a gamemode: allow action predictions in bucket 0, outside the safe zones. local function publish() TriggerEvent("open77_prediction:policy", { owner = GetCurrentResourceName(), enabled = true, bucket = 0, safeZoneRadius = 30, safeZones = { { x = -1448.0, y = 96.0, z = 17.0 } }, damageMultiplier = 1, meleeMultiplier = 1, explosionMultiplier = 1, headshotMultiplier = 1, }) end AddEventHandler("open77_prediction:requestPolicy", publish) AddEventHandler("onClientResourceStart", function(name) if name == GetCurrentResourceName() then publish() end end) AddEventHandler("onClientResourceStop", function(name) if name == GetCurrentResourceName() then TriggerEvent("open77_prediction:policy", { enabled = false }) end end) ``` Keep the multipliers equal to your server's damage policy: a contact that might be lethal is not predicted, so the victim is never shown getting up from a hit that killed them. Both players must be alive and in the policy's bucket, outside the safe zones and not already knocked down. The operator switches and the latency ceiling of `open77_prediction` apply on top of this policy. ## Advanced: native calls Resources rarely need these directly; the bundled resources call them. | Call | Permission | Purpose | |---|---|---| | [`Open77.prediction.stats()`](/docs/api/client/open77-prediction#stats) | none | `{ maxPingMs, ping, owner?, families }` with each family's `enabled`, `predicted`, `adopted`, `refuted`, `skippedDisabled` and `skippedLatency`. | | [`Open77.prediction.setPolicy(policy)`](/docs/api/client/open77-prediction#setpolicy) | `prediction.policy` | Sets what this client predicts: `{ families = { [name] = boolean }, maxPingMs = 0..5000 }`, or `nil` for the compiled defaults. Leave it to `open77_prediction`: a second caller replaces the server's policy on that client. | | [`Open77.motion.predictAction(source, entity, x, y, expectedDamage)`](/docs/api/client/open77-motion#predictaction) | `player.motion.project` | **Experimental.** Starts a melee, slam or hack knockdown prediction on a remote player's proxy; returns a request ID. | | [`Open77.motion.refutePrediction(request)`](/docs/api/client/open77-motion#refuteprediction) | `player.motion.project` | **Experimental.** Blends a refused prediction back to the live pose. | | [`Open77.motion.predictionStats()`](/docs/api/client/open77-motion#predictionstats) | `player.motion.project` | **Experimental.** JSON counters of the three action families. | ```lua -- Client: print this client's prediction counters. RegisterCommand("predstats", function() local stats, reason = Open77.prediction.stats() if not stats then print(reason); return end print(("ping %d ms, ceiling %d ms"):format(stats.ping, stats.maxPingMs)) for name, family in pairs(stats.families) do print(name, family.enabled, family.predicted, family.adopted, family.refuted) end end, false) ``` ## Limits - Prediction is presentation only. Damage, positions, deaths and door states remain the server's, and telemetry is informational. - "Refuted" follows each family's own rules, so rates are comparable over time within a family, not across families. - The round trip is the transport's smoothed measurement: a short spike may not stop a prediction, and a recovered connection may keep skipping for a moment. - Telemetry is reported by clients and bounded by the server; treat it as a health signal, not as evidence against a player. --- Source: https://open2077.net/docs/writing-a-gamemode # Writing a gamemode Build gamemodes as one or more Lua [resources](server-resources.md). Keep shared gameplay rules, match state and scoring authoritative on the server; use client resources for presentation and input. Examples use Pursuit and Race to demonstrate lifecycle handling, player readiness, routing buckets and server-side validation. ## The shape of a gamemode ```text resources// ONE server resource. All authority. open77.lua manifest shared/config.lua every tuning number server/main.lua roster, state machine, the placement primitive server/match.lua one round's lifecycle client/main.lua presentation and intent only resources/_hud/ separate, because a WebUI surface must be "reconnect" client/main.lua web/index.html ``` Plus whatever shared client services you consume: [`open77_worldui`](worldui.md), [`open77_zones`](zones.md), [`open77_interactions`](interactions.md), [`open77_notifications`](notifications.md). ### Files or separate server resources **Server resources can now call each other's exports.** Publish a service with `exports(name, fn)` and use `Open77.exports.call(...):await()` from a consumer. See [server exports](server-exports.md) for permissions, ownership and reload contracts. `TriggerEvent` remains per-VM; exporting a function does not expose it as a network event. Keep tightly coupled state-machine code in one resource when shared tables are useful, and split reusable services by resource when they need independent ownership and lifecycle. Files inside one resource share a Lua state: ```lua -- server/main.lua, at the end Pursuit = { players = players, transition = transition, placeAt = placeAt, ensurePlayer = ensurePlayer, } ``` ```lua -- server/match.lua Pursuit.placeAt(playerId, spawn.position, spawn.heading, match.bucket, "match_spawn") ``` Load order follows **manifest order**, so list `main.lua` before `match.lua`. Within a single wildcard pattern the expansion is ordinal, so one glob stays deterministic — but list scripts explicitly anyway, for the reason in the next section. Load shared definitions before scripts that use them. Manifest order determines execution order; a missing global usually indicates a missing declaration or an earlier startup error. For decoupling *inside* the resource, use a local event — same VM, so it works: ```lua -- main.lua: the queue announces itself and knows nothing about matchmaking TriggerEvent("pursuit:queueChanged", #queue) -- match.lua: matchmaking listens AddEventHandler("pursuit:queueChanged", function(size) ... end) ``` ### Why the client is many resources The client **does** have `exports`, so the split is real and worth making. Split on these, in order: 1. **Reload granularity.** Reloading a resource destroys every task, handler, marker and WebUI page its generation owned. Put anything you edit constantly — a lobby, a HUD — where reloading it cannot kill live state. 2. **Reload policy.** A WebUI surface needs `reload_policy "reconnect"`; a rules resource wants `"local"`. One manifest declares one policy. 3. **Least privilege.** `open77_worldui` holds `world.markers`; the gamemode does not. This is not ceremony — it catches real mistakes. A marker diagnostic written into the wrong resource failed immediately with `permission_denied:world.markers`. 4. **Reuse.** Anything not specific to your mode belongs in a shared resource. ## Eight things that will bite you ### 1. `client/**/*.lua` matches nothing `**` requires an intermediate directory, so it never matches a flat `client/main.lua`. The client then rejects the **entire resource set** with `script_pattern_empty:client/**/*.lua` and nobody can connect. List scripts explicitly, one per line. `**` is only safe for `files` and `web_files`, where the directory nesting genuinely exists. ```lua shared_script "shared/config.lua" server_script "server/main.lua" server_script "server/match.lua" client_script "client/main.lua" ``` ### 2. The roster does not rebuild itself after a reload `Open77.players.all()` answers with every authenticated player id, ascending, and needs no permission — `GetPlayers` is the same function. That call did not exist when the first gamemodes were written, which is why older code carries a roster assembled by hand from join and leave events. What has not changed is *when* you have to ask. A resource reload gives you a fresh VM with an empty table while the server is still full, and `onPlayerConnected` does **not** re-fire for players who were already connected. So seed from the host's roster on start: ```lua AddEventHandler("onResourceStart", function(resource) if resource ~= GetCurrentResourceName() then return end for _, playerId in ipairs(Open77.players.all()) do ensurePlayer(playerId) end end) ``` and keep the lazy path for everyone who arrives afterwards, because a player who connects between that read and your first handler is otherwise invisible until they do something: ```lua local players = {} local function ensurePlayer(playerId) if playerId == nil or playerId <= 0 then return nil end local record = players[playerId] if record == nil then record = { state = "lobby", sinceMs = nowMs(), graceUntilMs = nowMs() + Config.lobby.placementGraceMs, } players[playerId] = record end return record end ``` Call it at **every** entry point that carries a player ID: net events, commands, lifecycle handlers. ### 3. Player IDs arrive as strings ```lua AddEventHandler("onPlayerConnected", function(playerIdStr) local playerId = tonumber(playerIdStr) if playerId == nil or playerId <= 0 then return end ensurePlayer(playerId) end) ``` Skip the `tonumber` and your table keys silently diverge from the numeric IDs used everywhere else. (This is the opposite of the rule for *engine* IDs, which are 64-bit, opaque, and must never go through `tonumber`. Player session IDs are small integers and are safe.) ### 4. Move a living player with `teleport`, never a transform write A bare transform write over any real distance drops the player through a floor that has not streamed in, and the engine's own fall-under-world failsafe then returns them to the save's spawn kilometres away. Neither sanctioned move is a transform write. `Open77.players.teleport` is the placement primitive for a **living** player. Health, inventory, weapons and vehicle occupancy are untouched, it carries the fade and the bucket change, and it returns a promise that resolves only once the client reports the body settled on the mark: ```lua CreateThread(function() local placed, reason = Open77.players.teleport(playerId, position, { heading = heading, bucket = bucket, -- changed before the move; nobody in the old bucket sees the new place fade = true, -- default }):await() if not placed then return false, reason end -- placed.state is "settled", or "near" when the body never reported grounded end) ``` Requires `players.teleport`, which is deliberately not one of the `players.life.*` strings: a move is not a life event. It refuses rather than improvising — `player_not_ready` (no life record, which is the "press any key to continue" screen, where a server-side placement **crashes the client**), `player_not_alive`, `player_in_vehicle` unless you pass `dismount = true`, `invalid_position`, `settle_timeout` and `settle_superseded`. `Open77.players.respawn` is the other half and is **not** a general move: it refuses with `invalid_state` unless the phase is already `Dead`. The kill → respawn pair below is therefore for the case where a death is what your mode actually means, not for placing somebody: ```lua local function killAndPlace(playerId, position, heading, bucket, reason) local killed, killError = Open77.players.kill(playerId, { cause = "script", weapon = ":" .. reason, }) if not killed then return false, killError end return Open77.players.respawn(playerId, { position = position, heading = heading, bucket = bucket, health = 1.0, graceMs = 5000, }) end ``` Requires `players.life.kill` and `players.life.respawn`. **Consequence of that second path:** every placement made through it is a *death*. If you also have a "player died" rule, it will fire on your own spawns unless you guard it. Tag the kill and reject your own tag — and verify the tag actually survives the round trip before trusting it. ### 5. Never judge a player mid-transition Replicated positions are a snapshot stream. During a respawn the position is stale, or absent entirely. Two rules follow. Give every placement a **grace window** during which no rule evaluates that player. Otherwise placing someone in the lobby is itself what trips the leash that sends them there — a real bug, and a confusing one. Distinguish "outside" from **"cannot tell"**. An unreadable position must *freeze* accumulators, never reset them: ```lua local inside, detail = containsPlayer(playerId, centre, radius, bucket) if detail == "unknown" then -- leave the timer alone elseif inside then record.outsideMs = 0 else record.outsideMs = record.outsideMs + tickMs end ``` Every rule should be "held continuously for N seconds", sampled on a fixed tick. Never decide on a single sample. ### 6. One gamemode per server The shipped `freeroam` resource has `forceOnJoin` and `autoRespawn`, so it answers every kill → respawn another gamemode issues by respawning the player at *its* spawn point. The symptom is maddening: your placement "works" and the player is somewhere else a second later. Express the resource set with a dedicated root rather than trying to make two modes coexist: ```jsonc // server.jsonc "resources": { "root": "../resources-" } ``` ### 7. The life phase is spelled differently on the server The two hosts disagree on two of the five phase strings: | Phase | Client Lua | Server Lua | |---|---|---| | `RevivePending` | `revive_pending` | `revivepending` | | `RespawnPending` | `respawn_pending` | `respawnpending` | `alive`, `dead` and `recovering` are identical, which is precisely what makes this quiet. The client spells the names out by hand; the server pushes the C# enum member lowercased, which loses the word boundary. Server-side code written from the client spelling matches `dead` in testing and then goes blind the instant a corpse advances to `RespawnPending` — which is exactly when a death-detection guard is supposed to be watching. Do not compare the string when you only want to know whether a player is down: ```lua -- Both hosts. Resolved natively from the enum, immune to the spelling split. if Open77.players.isDead(playerId) then ... end ``` When the phase itself matters, normalise: ```lua local function phaseOf(life) return life and (life.phase or ""):gsub("_", "") or "" end ``` `cause` goes through the same server-side lowercasing, but every cause is a single word, so it agrees on both hosts. Phase is the only field that diverges. ### 8. Do not touch a player until the readiness gate has opened **The rule: do not teleport, spawn, kill or force a respawn on a player until `Open77.ready` says they are ready.** Everything else — reading their state, adding them to your roster, sending them a HUD payload — is unaffected. A player who has just connected is not necessarily a player anybody may act upon. Another resource may be about to open the character creator for someone with no saved character. Your join path, meanwhile, fires a kill → respawn the instant the client says its world is up — and both come from the *same* client announcement. This is not hypothetical: enabling the database for a ranked ladder also switched on persistence for the appearance package, which began opening a character creator on join while the gamemode teleported the same players to its lobby. Both resources were individually correct and the server was unusable. The barrier remains host-owned because it combines every participant and its generation across reloads. Server exports do not replace that readiness contract. ```lua RegisterNetEvent(":ready", function() local playerId = source local record = ensurePlayer(playerId) if not Open77.ready.isReady(playerId) then record.awaitingReady = true -- resume on onPlayerReady return end joinPlacement(playerId, record) end) AddEventHandler("onPlayerReady", function(playerIdStr, detail) local playerId = tonumber(playerIdStr) -- host events carry strings (§3) local record = players[playerId] if record == nil or record.awaitingReady ~= true then return end record.awaitingReady = nil joinPlacement(playerId, record) end) ``` `onPlayerReady` is a barrier lifting, not a trigger — keep your own readiness signal and ask the gate for permission. A gate can also open on a **timeout**, with the player still in a modal and no puppet at all, and placing an unincarnated player crashes their client: check `Open77.players.getLifeState(playerId)` and, if it is `nil`, leave the flag set and let their next announce do it. If your mode is the one that needs the player — a rules acceptance, a class pick, a spawn choice — you *hold* the gate instead. Holds, timeouts, the session number that survives a reconnect and the `ready` console command are all in [The join-time readiness gate](readiness-gate.md). ## Client-side patterns ### Rebuild on start, not only on `worldReady` `open77:worldReady` fires more than once per session — **and not at all after a hot reload**, because the world is already up. A client resource that only builds on that event loses everything it owned on the first reload and never recreates it. The full pattern, with teardown, is in [Drawing in the world](world-drawing.md#rebuild-on-start-not-only-on-worldready). ### Services that others call Ownership comes from `GetInvokingResource()`, **never** from an argument — otherwise any resource can act as any other. Sweep callers that stopped or reloaded: ```lua local ownerGenerations = {} local function caller() local owner = GetInvokingResource() local generation = GetInvokingResourceGeneration() if not validName(owner, 64) or type(generation) ~= "number" then return nil, "export_call_required" end if ownerGenerations[owner] ~= nil and ownerGenerations[owner] ~= generation then removeOwner(owner) end ownerGenerations[owner] = generation return owner end ``` ### Depending on a `reconnect` service `open77_interactions` declares `reload_policy "reconnect"`, so **any** change to the session's resource set restarts it, and on restart it drops every registered entry. Things you registered with it vanish while your own state says they exist. Watch its generation and re-register — or go through [`open77_worldui`](worldui.md), which already does that for you. ### Do not drive world-anchored UI from the Lua tick Anything that must sit *on* a world point — a nameplate above a player, a prompt on a door, a label over a circle — has to be re-projected to screen space as the camera moves. If that re-projection is driven from a Lua timer, it will visibly lag, freeze and slide behind the camera, however tight you make the interval. Register the anchor once from Lua; let native code project it and deliver it every frame. `Open77.anchors` is that mechanism, and [Drawing in the world](world-drawing.md#world-anchors--open77anchors) documents it in full. ## The client is never the authority The client draws hints. The server decides. ```lua -- client: report intent, carrying no claim about position TriggerServerEvent("pursuit:queueIntent") ``` ```lua -- server: re-derive from replicated state local inside, detail = containsPlayer(playerId, readyUpPosition(), Config.lobby.readyUp.radius, Config.lobby.bucket) if not inside then print(("queue REFUSED for %d: %s"):format(playerId, tostring(detail))) return end ``` Allow a **generous** grace on the server check — around three metres. Replication lag means an honest player's latest snapshot may not have landed; the check exists to stop a client claiming to be somewhere else entirely, not to arbitrate centimetres. ## Isolating a round Routing buckets are network dimensions. Give each match its own: ```lua Open77.routingBuckets.setPlayer(playerId, bucket) Open77.routingBuckets.setPopulationEnabled(bucket, false) Open77.routingBuckets.setLockdownMode(bucket, "relaxed") ``` The low-level globals `SetPlayerRoutingBucket`, `SetRoutingBucketPopulationEnabled` and `SetRoutingBucketEntityLockdownMode` are equivalent; the namespaced form is preferred. Allocate from a reserved range, track the owner so two matches never share one, and release on resolution. Remember to put players **back** in the lobby bucket when the round ends. ## The conventions a mode's server should implement There is no shared server-side kernel resource to inherit these from, and there cannot be — see [The gamemode kernel](gamemode-kernel.md) for why. These are conventions to copy, proven across two gamemodes. | Convention | Why | |---|---| | Lazy roster adoption | A reload must not lose track of connected players. | | `tonumber` every player ID | A raw string key silently diverges from the numeric IDs used everywhere else. | | Guarded state transitions in one function | An invalid transition is more useful as a log line than as silent corruption. | | Move players only through kill → respawn | A direct teleport over distance drops the player into unstreamed world. | | Re-derive every client-reported condition | The client is never the authority. | | Never judge a single position sample | The position is a replicated snapshot, not a live read. | | One `.where`-style diagnostic, early | Nearly every confusing failure is answered in one line by such a command. | | Isolate a round in its own routing bucket | Two rounds must never see each other, and neither should inherit the lobby's crowd. | A single guarded `transition(playerId, target, detail)` that checks an explicit table of allowed edges — and logs a refusal instead of corrupting state — is worth writing on day one. ## Settings the server owner can retune Every number in `shared/config.lua` is a decision you made once. Some of them the owner of a server running your mode will want to make differently, tonight, between rounds, without a restart. Declare those and Warden's Tuning tab renders them as a form. Three decisions are yours to get right: - **Declare, do not expose.** The declaration table is data and belongs in `shared/config.lua`; the `Open77.tunables.declare` call is server-only. Put `declare` in a `shared_script` and every connecting player fails the resource set. - **Read at the point of use.** The proxy reads through to the host on every access, so a value hoisted into a *file-scope* local is frozen at load and live tuning silently does nothing. - **Decide when a change may land.** `live`, `next_round`/`next_match` with `promote()`, or `capture()` per match. If two rounds can run at once, `capture()` is the only correct answer — `promote()` is per-resource, so promoting at one match's creation moves the other's finish line too. The full reference is in [Operator tunables](tunables.md). ## Testing Test in the real game. Two habits pay for themselves. **Add a `.where`-style diagnostic early.** Print what the *server* sees: position, bucket, computed distance, the verdict and the reason. Nearly every confusing failure this project has hit was answered in one line by such a command, after being guessed at for far longer. **Do not let the instrument change the measurement.** A probe that logged every sample reported ~291 ms intervals; buffering the samples and logging once showed the real figure was ~14 ms. Log writes cost frame budget. Buffer, then log. When you verify a connection, prove it with all three signals: the `worldReady matched pristine transition` log line, a readable `position`, **and** a life state of alive. A session that is merely "active" is not an incarnation, and acting server-side on a client that is not alive can crash it. [Autonomous agent testing](agent-testing.md) describes the tooling that automates this. ## Scaffolding The platform repository ships a generator that emits a correct-by- construction starting point: ```powershell pwsh -File .\scripts\new-resource.ps1 -Name -Kind gamemode pwsh -File .\scripts\new-resource.ps1 -Name _hud -Kind hud pwsh -File .\scripts\new-resource.ps1 -Name -Kind service ``` It produces correct manifests (explicit script lists, minimal permissions), the right lifecycle event names, string-safe player IDs, and the service ownership guard above. Code generation remains useful for local patterns; [server exports](server-exports.md) provide runtime sharing between services. Both `pursuit` and `race` began this way and then diverged, which is what a generated starting point is for. ## See also - [The gamemode kernel](gamemode-kernel.md) — shared server services and state-machine conventions. - [The join-time readiness gate](readiness-gate.md) — holds, timeouts and the session number. - [Operator tunables](tunables.md) — declaring what an owner may retune. - [Drawing in the world](world-drawing.md) — markers, anchors, POIs, zones and nameplates. - [The Lua resource runtime](resource-runtime.md) — manifests, the scheduler, events, exports, WebUI and the sandbox quotas. - [Complete server Lua API](server-api.md) — every server global, namespace, permission and result shape. - [Commands and ACL](server-acl.md) — putting an admin command behind a permission. --- Source: https://open2077.net/docs/gamemode-kernel # The gamemode kernel and shared server services Organize gamemode rules in server resources and expose reusable services through [server exports](server-exports.md). Open77 does not ship a universal `open77_gamemode` resource. Keep tightly coupled match state in one resource. Extract reusable services when they need an independent lifecycle and a clear request interface. ## Shared server resources are supported The server runtime now provides `exports(name, fn)`, `Open77.exports.call(resource, name, ...):await()`, `GetInvokingResource()` and generation identity. See [server exports](server-exports.md) for a runnable two-resource example and the precise lifecycle contract. Shared services can own scores, lobbies or bucket allocation. Values cross resource boundaries by copy. Synchronous exports must not yield; asynchronous calls are deferred and awaited. Validate callers and arguments, and keep state changes atomic before yielding. `TriggerEvent` remains local to a VM. The host still fans lifecycle/player events into resources. Export registration does not create a network entry point. Client services such as `open77_zones` and `open77_worldui` remain client-only packages; adding server exports does not move their presentation logic to the server. ## The scaffolder remains useful `scripts/new-resource.ps1 -Kind gamemode` creates a resource with a guarded state machine, reload-safe roster handling and a `.status` command. Use generated code for tightly coupled state-machine rules, and server exports for reusable services with a clear ownership boundary. See [writing a gamemode](../docs/writing-a-gamemode.md) section 7. No forced migration or new dependency is added to existing gamemodes by the runtime change. ## The contract every gamemode's server should implement Apply these lifecycle and authority conventions to each gamemode. See [Writing a gamemode](writing-a-gamemode.md) for examples. | Convention | Why | Where it lives | |---|---|---| | **Reload-safe roster adoption.** Use `Open77.players.all()` for the current roster and retain lazy `ensurePlayer` handling at player-event boundaries. | A reload must not lose track of connected players. | `ensurePlayer` in [`resources/gamemodes/pursuit/server/main.lua`](https://github.com/Open2077/open77-resources/blob/main/gamemodes/pursuit/server/main.lua) and `resources/gamemodes/race/server/main.lua` | | **`tonumber` every player ID.** IDs arrive from net events and lifecycle handlers as strings. | A raw string key silently diverges from the numeric IDs used everywhere else. | Every `AddEventHandler("onPlayer...", ...)` in both resources | | **Guarded state transitions, one function.** A single `transition(playerId, target, detail)` that checks an explicit table of allowed edges and logs a refusal instead of corrupting state. | An invalid transition is more useful as a log line than as silent corruption. | `transition` in both resources | | **Move players with `Open77.players.teleport`.** Never a raw transform write. Kill -> respawn was the ONLY placement primitive before `teleport` existed -- `Open77.players.respawn` refuses unless the player is already dead -- and the modes that still do it are unconverted, not exemplary. | A raw transform write over distance drops the player into unstreamed world and the engine's fall-under-world failsafe silently returns them to the save's spawn. `teleport` carries the fade and waits for the client to report the body settled, without costing a death. | `placeAt` in both resources (kill -> respawn, pre-`teleport`); [Moving a player](server-api.md#moving-a-player) | | **Re-derive every client-reported condition on the server.** A zone `enter` event, a checkpoint claim, a queue intent -- all are hints. Re-check them against `Open77.players.position` with a few metres of grace before granting anything. | The client is never the authority (writing a gamemode, section 4). | `containsPlayer` (Pursuit) / `checkpointReached` (Race) | | **Never judge a single position sample.** Every rule is "held continuously for N seconds," evaluated on a fixed tick; an unreadable position freezes an accumulator, never resets it. | `Open77.players.position` is a replicated snapshot, not a live read (writing a gamemode, section 2.5). | Pursuit's win-condition tick; Race's per-second heat-state check | | **One `.where`-style diagnostic, early.** Print exactly what the server sees for one player -- position, bucket, the rule's own verdict and reason. | Nearly every confusing failure in this project was answered in one line by such a command (writing a gamemode, section 6). | `pursuit.where`, `race.where` | | **Isolate a round in its own routing bucket.** Allocate from a reserved range, disable ambient population, release on resolution. | Two rounds must never see each other, and neither should inherit the lobby's crowd. | Pursuit's per-match bucket pool; Race's single heat bucket | ## What this means for a new mode Run the scaffolder and build the state machine your mode needs. Extract a reusable service when its contract justifies a separate lifecycle; declare the dependency for startup ordering and use generation-bound server exports to call it. Keep player readiness and authority checks intact across the new boundary. Existing shared client presentation services are documented separately: - [Proximity zones](zones.md) (`open77_zones`) - [World-anchored POIs](worldui.md) (`open77_worldui`) --- Source: https://open2077.net/docs/tunables # Operator tunables Expose bounded configuration values through Warden's Tuning tab. Operators can change declared settings without editing Lua or restarting the resource. A resource **declares** which of its numbers are tunable, with bounds and a description written for an operator. Warden's Tuning tab renders that declaration as a form, and the owner moves the values live. The panel can only move a value inside the declaration, and it never edits Lua. `Open77.tunables` is **server-side only**. It does not exist in the client runtime. ## The API | Function | Signature | Result | |---|---|---| | `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. | The owning resource — and no other — receives `onTunableChanged(key, value, pending)` after every accepted write. ## Declaring ```lua -- shared/config.lua — data, beside everything else you tune Config.tunables = { bustRadius = { value = 15.0, min = 5.0, max = 50.0, unit = "m", apply = "next_match", label = "Bust radius", group = "Chase", description = "How close the cop must be for the pursuit meter to fill." }, meterFill = { value = 3.0, min = 0.5, max = 30.0, unit = "s", apply = "live", label = "Meter fill", group = "Chase" }, } ``` ```lua -- server/main.lua — the call is server-only Tune = Open77.tunables.declare(Config.tunables) ``` **That split is not cosmetic.** The table is data and belongs with the rest of your configuration; the `declare` call must be server-side, because `Open77.tunables` does not exist on the client. Put `declare` in a `shared_script` and every connecting player fails the resource set. The declaration table itself *is* downloaded to clients, so keep its prose publishable. | Field | Meaning | |---|---| | `value` | The default. Required. | | `type` | `number`, `integer`, `boolean`, `string`, `enum`. | | `min`, `max` | Enforced on every write. | | `choices` | For `enum`. Enforced. At most 32. | | `step`, `unit` | Presentation only — never enforced. | | `apply` | `live`, `next_round` or `next_match`. See below. | | `label`, `description`, `group`, `order` | How the panel renders it. | Limits: 128 tunables per resource, 64 KiB of declaration, 32 choices, 256-character strings. The declaration is a contract, not a hint. `min`/`max`, the type and the choice list are enforced on **every** write — from the panel, from the console, and from your own `Open77.tunables.set`. Pick bounds inside which your resource is still doing something sensible; anything outside them is not a setting, it is a bug report. Write the `description` for someone who has never read your code. It is the only thing standing between an owner and a number they will change by feel. ## Read at the point of use The proxy returned by `declare` **reads through to the host on every access**. `Tune.bustRadius` is a call, not a field. ```lua -- correct if distance <= Tune.bustRadius then bust(playerId) end ``` ```lua -- wrong, and quietly so local radius = Tune.bustRadius -- file scope: frozen at load CreateThread(function() while true do Wait(200) if distance <= radius then bust(playerId) end end end) ``` The second reads its value once, at load, and never again. The panel goes on reporting the new number, so the symptom is "live tuning does nothing" and the cause is three files away. **A local inside a function is fine** — it lives for one call. A local at file scope is the bug. A misspelled key **raises** rather than returning `nil`, which is the one thing that makes this catchable at all. ## When a change is allowed to land Three tools, in increasing strength. | `apply` | What the host does | Use it for | |---|---|---| | `live` | The next read sees the new value. | Anything no rule in flight depends on: a HUD colour, a lobby radius, an announcement interval. | | `next_round` / `next_match` | Stores and persists the value immediately, but `Tune.key` keeps returning the old one until you call `promote()`. | A rule that decides an outcome, in a resource that runs **one** round at a time. | | `capture()` | You freeze the whole set onto a match, and that match's rules read the capture. | A resource where **several rounds run at once** — the only correct answer there. | The middle row has a limit that is easy to miss: **`promote()` is per-resource, not per-match.** If match A can be in flight while match B is created — one routing bucket each — promoting at B's creation moves A's finish line too. Capture instead: ```lua -- creating a match match.tune = Open77.tunables.capture() -- every rule inside that match, for its whole life if distance <= match.tune.bustRadius then ... end ``` A resource that captures does not need `promote()` at all: declare those keys `live` and let the capture do the holding. ## Where the values live 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`. Stopping a resource for an evening must not quietly undo an afternoon of tuning, so the reset lever for a tunable is an explicit reset — not a restart. (This is deliberately unlike [`Open77.state`](resource-runtime.md#carrying-state-across-a-reload), whose bag a stop *does* drop, so an operator keeps a "come up as at boot" lever.) 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()`. ## See also - [Writing a gamemode](writing-a-gamemode.md) — choosing what to expose. - [The join-time readiness gate](readiness-gate.md) — the other host-owned cross-resource mechanism. - [The Lua resource runtime](resource-runtime.md) — manifests, permissions and the sandbox. --- Source: https://open2077.net/docs/deathmatch # Deathmatch — running the Kabuki Arena PvP mode Configure and run the Kabuki Arena PvP mode. This guide covers installation, match settings, dependencies and troubleshooting. The mode is two resources: `open77_deathmatch` (authoritative) and `open77_deathmatch_hud` (presentation). The template that installs them is `templates/deathmatch`. The configuration reference includes options that may not be connected to gameplay in every package. Use `resources/gamemodes/open77_deathmatch/shared/config.lua` as the authoritative configuration schema; do not treat an option's presence as a gameplay guarantee. Source: [`open77_deathmatch`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_deathmatch), [`open77_deathmatch_hud`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_deathmatch_hud). ## What players get Two formats with genuinely different lifecycles, which is why the mode does not try to run them through one state machine. | | Free-for-all | Arena (1v1 · 2v2 · 3v3) | |---|---|---| | Entry | Instant: hold **E** on the lobby ring and you are alive and armed inside a live instance. | Press **E**, pick a size, queue. Matched when the roster fills. | | Waiting | **Never.** | The queue — the only wait in the mode, and the format demands it. | | Instance | Persistent, auto-scaled, capacity 12. | Allocated per match, released on the result. | | Death | Respawn after a delay, same round. | Eliminated for the round; spectate your team. | | Round | Kill limit or timer, then standings, then the next round with the same players. | Team wiped or timer; round point, sides swapped. | | Match | Never ends. | Best-of-*N*, then everyone returns to the lobby. | | Loadout | One rotating weapon, identical for everyone. | A kit chosen in the buy window; both sides get the same list. | **Free-for-all never queues.** The only refusal it can produce is `instances_full`, and that means *every instance on this server is at capacity* — a server-full condition, not matchmaking. If players see it, raise `ffaCeiling` or accept that the box is full; there is no queue to lengthen. **Joiners fill before they spread.** A player lands in the fullest instance that still has room, and a new instance opens only when none does. A 2-of-12 instance is a dead lobby, and two of them are worse than one busy one. ## Running one ### From the setup wizard Start `Open77.Server` with no `server.jsonc` (or `--setup`) and pick the **Open77 Deathmatch** template. It installs the resource set below and proposes the tags `deathmatch`, `pvp`, `ffa`, `arena`, `english`. `requiresDatabase` is `false`: the mode plays completely without persistence. See [Persistence](#persistence-and-one-warning) before turning the bridge on. ### From the tracked dev profile `server/server.deathmatch-local.jsonc` is a masterless loopback-only profile for development and for the autonomous test loop: ```powershell dotnet run --project src/Open77.Server -c Release -- --config server.deathmatch-local.jsonc ``` Then connect a client to `127.0.0.1:11798` with no master. **Its ports are shifted by +20 — 11798 game, 11799 download, 11800 Warden — and that is not tidiness.** A workstation running several stacks at once has already claimed 11778/11779/11780, and two stacks on one port do not fail loudly: the second bind loses, or a client resolves the default port and quietly attaches to somebody else's server. You then debug your gamemode against a process that is not running it. The download cache directory is separated for the same reason — two servers publishing different resource sets through one cache surfaces as a signature or chunk mismatch on a *client*, nowhere near the server that caused it. ## The resource set ``` open77_shell open77_pause open77_watermark open77_chat open77_nameplates open77_notifications open77_death open77_appearance open77_weapons open77_interactions open77_worldui open77_groundcircle open77_zones open77_voice open-voice open77_deathmatch open77_deathmatch_hud ``` The four in the middle are the way into the mode, and each one is load-bearing: | Resource | What breaks without it | |---|---| | [`open77_worldui`](worldui.md) | No station at all. It owns the ring, the prompt card and the action key. | | [`open77_interactions`](interactions.md) | Required by both the gamemode and `open77_worldui`. If missing, startup fails with `missing_dependency:open77_interactions` or a repeated `Automatic resource start failed` message. Add it to the resource set. | | [`open77_groundcircle`](https://github.com/Open2077/open77-resources/tree/main/system/open77_groundcircle) | The station works and is invisible. On 2.31 the native 3D interaction ring **does not draw at all**; the ground circle is the entire visual, and it follows real ground per vertex. | | [`open77_zones`](zones.md) | No client-side boundary hysteresis. The server still re-derives every containment claim, so this is a smoothness loss rather than an authority one. | Two more rules about the set: - **`freeroam` must never be in it.** Both are gamemodes and both own spawn and death; `freeroam`'s `autoRespawn` answers every `kill → respawn` placement this mode issues, so players respawn at the freeroam spawn point mid-round. One server, one gamemode — the resource list is how that is expressed. - **[`open77_npcs`](https://github.com/Open2077/open77-resources/tree/main/system/open77_npcs) is not required for bots.** It is the reference resource for the [NPC API](npcs.md); the API itself is a server global available to any resource holding the `world.npcs` permission. Editing the set on a running server: `open77_interactions` carries `reload_policy "reconnect"`, so a change touching it reaches connected players only when they reconnect. ## Buckets and instances Every instance owns a routing bucket, which is what keeps two fights from seeing each other. Population is disabled and entity lockdown relaxed per bucket. | Range | Use | |---|---| | `4100` | Lobby. The one place in the mode with no combat. | | `4200 – 4231` | Free-for-all instances (32). | | `4240 – 4287` | Arena matches (48). | Allocation is first-free from the range, with the owner tracked, and the bucket is released on reap — two instances can never share one. **The instance ceiling is a bucket-range choice, not a performance or prop limit.** That is a change from the original design: instances were going to carry a prop perimeter, and props are capped at 256 per resource, which would have capped instances too. The wall turned out to be client-side (below), so the quota stopped gating anything. Objects belonging to a match—props, markers and NPCs—must use that match's routing bucket. This keeps instances using the same world coordinates isolated. **Reap.** An instance that reaches zero players is not torn down at once: a player crossing between rounds or a single reconnect would churn the bucket. It lingers for `emptyLingerSeconds`, then releases. `keepOneWarm` holds the last instance open; it is off by default, because the wall build it was meant to spare the first joiner no longer exists. ## Tunables Declared in `resources/gamemodes/open77_deathmatch/shared/config.lua` and rendered by the Warden **Tuning** tab, so retuning a running server never means editing Lua and never means an SSH session. Bounds are enforced on every write — from the panel, from the console, and from the mode's own code — so a value the panel accepts is a value the mode can run. **When a change takes effect.** Every key is declared `apply = "live"`, and that is not laziness. This mode runs several rounds at once, and `promote()` is per-*resource*, not per-match — promoting when instance B opens would move instance A's finish line too. So the kernel **captures** the whole set onto an instance when the instance opens, and every rule inside that instance reads its own capture for the instance's whole life. The operator consequence is worth knowing before you touch a slider mid-evening: **your change reaches instances created after it, and never a round already in flight.** ### Instances | Key | Type | Default | Range | What it does | |---|---|---|---|---| | `ffaCapacity` | integer | `12` | 2 – 24 | Players per free-for-all instance. Twelve because fourteen surveyed spawn marks leave the headroom the maximin spawn choice needs to mean anything; raise it and spawns start colliding before the marks run out. | | `ffaCeiling` | integer | `32` | 1 – 32 | How many FFA instances may run at once, bounded by the bucket range above. Reaching it is what produces `instances_full`. **Lowering it never closes an instance — it refuses the next one.** | | `arenaCeiling` | integer | `48` | 1 – 48 | The same ceiling for queued arena matches. | | `emptyLingerSeconds` | integer | `60` s | 0 – 600 | How long an emptied instance keeps its bucket before releasing it. Too short and a reconnect churns buckets; too long and idle instances hold the range. | | `keepOneWarm` | boolean | `false` | — | Never reap the last FFA instance. **Off by default, and the reason is a measurement:** it was meant to spare the first joiner a wall build, and there is no wall build — the wall is a client clamp, so opening an instance is a table insert and two bucket calls. It survives only because it also keeps that bucket's population and lockdown state settled. | ### Free-for-all round | Key | Type | Default | Range | What it does | |---|---|---|---|---| | `roundSeconds` | integer | `300` s | 60 – 1800 | Maximum length of one FFA round. Captured when the round starts. | | `killLimit` | integer | `25` | 1 – 250 | First player to this many kills ends the round. | | `respawnDelayMs` | integer | `3000` ms | 500 – 15000 | Delay between a counted death and the arena respawn. | | `standingsSeconds` | integer | `10` s | 3 – 60 | How long the standings are shown between rounds. The instance never ends, so this is a pause, not a result screen. | There is deliberately **no minimum-player setting and no join countdown** for free-for-all. A lone player in a fresh instance is playing, not waiting. ### Arena | Key | Type | Default | Range | What it does | |---|---|---|---|---| | `arenaBestOf` | integer | `5` | 1 – 9, odd | Round wins needed to take a match. Odd, so there is no draw to explain. | | `arenaRoundSeconds` | integer | `120` s | 30 – 600 | Time limit on one elimination round before it is scored on survivors. | | `arenaBuySeconds` | integer | `20` s | 5 – 90 | Time to pick a kit before a round. Both teams get the same list. | ### Combat | Key | Type | Default | Range | What it does | |---|---|---|---|---| | `spawnProtectionSeconds` | number | `5.0` s | 0 – 15 | Invulnerability after a respawn. Five seconds is long for a twelve-player free-for-all and short for a contested spawn; this is the slider that decides which. | | `spawnProtectionBreaksOnFire` | boolean | `true` | — | Drop a player's protection the moment their own outgoing damage is credited. Turning this off re-enables the classic abuse — shooting out of a shield — so it exists to be measured against, not to be flipped. | | `boundsBackstopSeconds` | number | `5.0` s | 1 – 30 | How long a player must be held **continuously** outside their zone before the server places them back inside. The wall itself is enforced on the client; this is the authority behind it, and a backstop placement costs 100 points so the wall is never a strategy. | Damage multipliers are **not** in this list, and that is a platform constraint rather than an oversight: combat policy is host-global, so `setDamageMultiplier` and friends apply to every instance at once. Per-instance damage tuning is not available, and faking it in the damage arbiter is not something to do without measuring first. ### Bots | Key | Type | Default | Range | What it does | |---|---|---|---|---| | `fillWithBots` | boolean | **`false`** | — | Fill underpopulated free-for-all instances with bots. Disabled by default. | | `botFillTarget` | integer | `6` | 0 – 23 | How many bodies — players plus bots — a backfilled instance aims for. Bots give way as humans arrive. | | `botDifficulty` | enum | `standard` | `relaxed` · `standard` · `veteran` | Reaction delay, hit chance by range band, burst length and aggression. The first thing anyone says about a new bot is that it is too good or too stupid; this is the answer to both. | ## Bots — what they are and are not Bots exist because the sizes this mode is built for cannot be tested with real clients on one workstation: roughly 2.7 GiB of VRAM per client at the menu rising to 4.2 GiB in play, and a fourth client on a 12 GiB card died on its own with 155 MiB free. Six clients for a 3v3 is not happening. They **move with the engine's own navigation and shoot on the server**. Native attack and combat tasks are not in the stable API, so the gun is a server tick that rolls a hit against range and line of sight and applies damage directly — no engine gunplay, nothing experimental. Two rules that are not tunable: - **Bots are labelled**, on the scoreboard and in the kill feed. A player who cannot tell whether they lost that duel to a person has been cheated of the only thing the mode sells. - **Bots never write to the ladder.** A round containing one is scored, shown, and then not persisted. Excluded at the write, not filtered at the read. - **Bots fight with Cyberpunk's own combat AI.** The mode does not pick their targets, walk them, or subtract anybody's health on a timer. It spawns a stock hostile gang record, stays out of its way, and replicates the result. So a bot acquires, takes cover, aims, draws its weapon and shoots — and it knows what it can and cannot see, because the engine's raycast is doing that work and not a server-side approximation of it. What to expect as an owner: - **A roster is three gangs, and the rivalry is load-bearing.** Bots alternate between Maelstrom, Valentinos and Tyger Claws. That is not decoration: whether two bots fight each other is a question the engine's attitude matrix answers about their two records, and one faction is one side. A single-gang roster gives you bots that ignore each other and only ever attack players. - **`civilian_female_relaxed_01` is not on the roster and must not be added.** It resolves to `Character.Panam`, tagged `Invulnerable` in TweakDB — quest protection that no setting can lift. It used to be half the roster, which is the whole of "I empty a magazine into a bot and nothing happens". - **Bots can act on things the mode did not tell them to.** Native combat is not opt-in and cannot be switched off, so a bot may chase a player toward the arena edge or take an interest in a passing vanilla NPC. The mode leashes them — a nudge back inside first, a placement if that is ignored — but a bot briefly out of position is expected behaviour, not a fault. - **A bot alone in an instance is frozen, and that is correct.** Native navigation needs a client authority lease; with no ready client, tasks suspend and resume when one arrives. It is not a fault, and it does not need reporting. - **Two players may see the same bot in slightly different places.** Every client runs its own copy of a bot's AI, so their paths can diverge. Health cannot: the server ledger is the only thing that writes a health number, so a bot dies at the same moment for everybody. ## One map, four formats The map is a set of axis-aligned boxes — not a cylinder, which would either clip a gallery six metres up or swallow the street outside, and not a polygon prism, which needs an ordered ring and a winding test and buys nothing at this scale. A point is inside a zone if it is inside any of that zone's boxes: six comparisons, trivially correct, and capturable by a surveyor who walks to one corner and then the opposite one. Cutting volumes is how one surveyed map serves four formats, and it is why a second map is out of scope rather than merely unbuilt: | Format | Zone | Volumes | Why | |---|---|---|---| | Free-for-all | `full` | the whole footprint | Vertical play and four stair chokes are what make the space interesting at 8–12 players. | | 3v3 | `full` | the whole footprint | A real site fight with two approaches per team. | | 2v2 | `mid` | market and lower | Cutting the gallery removes overwatch and shortens the fight. | | 1v1 | `duel` | lower only | A duel wants one room and no third angle. | Until the Kabuki survey is captured the mode runs on **provisional** geometry — boxes circumscribing the cylinders the previous version already enforced, and the eight walked promenade marks. Every provisional entry says so in the config, and the startup log says so once. ## The wall The arena boundary is a **client-side clamp validated on the server**, not a barrier you can build. The obvious approach was a ring of solid props, and it was tested. Props **are** solid — a player walked 0.47 m into a barrier ring where the same walk with the ring cleared covered 7.91 m, a seventeen-fold difference in the same spot. But `visible = false` is *accepted* and the prop **renders anyway**, so no arrangement of props produces an invisible wall. A jersey barrier is also about a metre tall, which the owner cleared by jumping: a boundary needs height, not merely presence. So: the client projects a player who has left every volume back inside, the server keeps its leash as a **backstop** rather than as the mechanism (a clamp that can be disabled is not authority), and the boundary is **drawn**, because an unmarked wall reads as a bug. If you want to decorate the line with props, that is supported and purely cosmetic — **do not expect props to stop anyone invisibly, and do not file it as a bug when they do not.** One practical note if you do: [`open77_props`](props.md) is not in this template's set, and a server-created prop is only projected onto clients by that resource's client half, so props added without it exist on the server and appear to nobody. ## Commands and access control Available to any player: | Command | Effect | |---|---| | `/dm` | Opens the panel. | | `/deathmatch.join` | Registers for the next match. | | `/deathmatch.leave` | Leaves the queue, the instance or the match. | | `/deathmatch.status` | Prints the authoritative phase and population. | The station in the world is the intended way in; the commands survive as an admin and power-user fallback. `/dm.where` is also open to everyone, and **self-only for a player** — it takes a format, never another player's id, because an id argument would let anyone read anyone's position. From the server console, which has no position of its own, the id is required instead: ```text dm.where [ffa|3v3|2v2|1v1] # in game, about yourself dm.where [ffa|3v3|2v2|1v1] # from the console ``` It prints what the *server* sees: position, bucket, readiness, life state, the containing volume, the verdict and the reason. Nearly every confusing failure in this project was answered in one line by a command like this, after being guessed at for far longer. It is the first thing to run when a player says the mode is broken. Restricted to ACL principals — grant these from the Warden **Access** page, not by hand-editing `acl.jsonc`: | Command | Effect | |---|---| | `/dm.survey start\|mark\|box\|list\|dump\|clear` | Map capture: opens a session, appends spawn marks and volume corners, prints the whole set as pasteable Lua. Coordinates are never accepted from a browser payload — every one is read from `Open77.players.position`. Marks are captured **while walking the deck continuously**; nothing is interpolated, offset or averaged, because nearby XY does not imply nearby walkable ground. | | `/dm.bot add [n]` · `fill` · `clear` | Bot population inside your instance. *(phase 3)* | `Open77.players.position` carries **no heading**, so `dm.survey mark` cannot see which way you were facing: pass the yaw yourself (`dm.survey mark `, read from the debug bridge) or the mark records `0.0` and every player placed on it faces the same arbitrary direction. See [Identity and ACL](server-acl.md) for how principals are authenticated. ## Persistence, and one warning The mode plays completely with `database.enabled: false`, and nothing is gated behind persistence — no unlocks, no power curve. Career stats and per-format ELO are additive. The database bridge is configured per server, not per resource. Enabling it also activates database-backed behavior in other resources, including character persistence. Check their configuration and join flows together. ## When something looks broken | Symptom | Cause | |---|---| | The log prints `Resource 'open77_interactions' was not found` once a second, forever. | `open77_worldui` is in the set and `open77_interactions` is not. The server does not give up and does not start the set partially. Add it. | | The station is not visible, but holding **E** in the right place works. | `open77_groundcircle` is missing. The native 3D ring does not draw on 2.31; the ground circle is the whole visual. | | Two stations are close together and neither prompt appears between them. | `open77_worldui` activates at `radius + 0.5 m`. Stations closer than the sum of their activations leave a dead band where neither is live. Move them apart — an overlap is arbitrated, a dead band just looks broken. | | A green station shows a cyan card. | The prompt colour defaults to `#00E5FF` regardless of style. Pass `color` explicitly. | | The station follows a player into a match and offers to queue from another dimension. | Furniture must be torn down when the player leaves the bucket. `maxDistance` is not the lever; ownership is. | | Players respawn somewhere else entirely, mid-round. | `freeroam` is in the resource set. Its `autoRespawn` is answering this mode's placements. | | Players see `instances_full`. | Every instance is at capacity. It is a server-full message, not a queue — raise `ffaCeiling` or add capacity. | | A player was invulnerable for a few seconds and nobody could tell. | Spawn protection is working. Until the HUD ring lands *(phase 7)* it is invisible, and invisible invulnerability is indistinguishable from a hit-registration bug. | | A bot stands still in an empty instance. | Expected. Native navigation needs a client authority lease; tasks resume when a client is present. | | A tunable change did nothing to the round being played. | Also expected. Numbers are captured per instance at creation; the change applies to instances opened after it. | ## See also - [The gamemode kernel](gamemode-kernel.md) — modular server services and the conventions every mode's state machine implements. - [World-anchored POIs](worldui.md) and [Proximity zones](zones.md) — the two shared client services this mode's entry station is built from. - [NPCs](npcs.md) — the API the bots are built on, including what is not yet in it. - [World props](props.md) — models, buckets and ownership, for decorating the boundary. - [`docs/writing-a-gamemode.md`](../docs/writing-a-gamemode.md) — for owners who want to change more than a number. --- Source: https://open2077.net/docs/cordon # Cordon — battle royale Cordon is a 64-slot battle royale with staging, equipment collection and a moving exclusion zone. Solo, Duo and Squad formats share the same match rules. Source: [`open77_cordon`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cordon), [`open77_cordon_hud`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cordon_hud). ## Joining and controls The current lobby is Afterlife. Staging players can move and sprint; combat and placement are owned by the mode. Joining an ongoing server leaves you in staging until a later match. | Default control | Action | |---|---| | **Y** | Join or leave the next match's queue. | | **M** | Open the tactical map; Escape closes it. | | **F**, on an active loot prompt | Request the nearby container's contents. | | **END** | Start applying an armor plate. | | **F8** | Start using a healing item. | | **F**, on a downed squadmate's prompt | Start reviving; tap F again to cancel. | Queue, map and consumable actions use the key mapping system. Their HUD hints show the effective binding, including changes made in Settings. F remains the world interaction prompt's key. **F6 and F7 are perspective controls**, not consumable defaults: the perspective resource uses F6 and the native client uses F7. Rebinding an action to an occupied key can trigger both functions. ## Match flow `staging → deploy → live → extraction → resolved → staging` - **Staging:** the queue determines participation. A minimum human count and a countdown gate deployment; bots do not satisfy the human requirement. - **Deploy:** use the map to choose a landing pin. Ground placement is the default (`drop=false`), not a ride in an aircraft. - **Placement confirmation:** the match phase may already read `live` while an individual is still `deploying`. The HUD says DEPLOYING while the server waits for the exact native placement acknowledgement. Loot, cordon timing and live participation start when the deployment wave completes. Failed placements return to staging without inventing a death or ranked finish. A match needs at least two confirmed squads to proceed. - **Live:** loot, fight and rotate toward the safe area. Dead players with a living squadmate may have a revive window; eliminated players watch from staging. - **Extraction and results:** the surviving contestant or squad receives the match result, followed by standings and the return to staging. The implemented extraction is a match/result phase. **There is no proven playable Trauma Team AV pickup or passenger flight.** Default source tunables are **Solo**, **Watson**, **standard** pacing, `minPlayers=16`, staging 105 s and deploy 45 s. Development profiles override some of these. The standard cordon budget is 45 s initial grace, 300 s of closure and 25 s final phase; this is about 6 m 10 s of zone timing, not an 18-minute default match. Early wins can end a match sooner. ## The moving cordon The authoritative safe area is a continuously shrinking rectangle intersected with the map's original allowed cells. Excluded cells and holes stay excluded. The chosen final cell varies between matches, with recent finals avoided; the phase endpoints still follow the map's block geometry. During a closure the rectangle moves continuously. A partially covered cell is partly dangerous: an entire cell does not remain safe until its last sliver closes. The final refuge remains until the terminal phase ends, then the safe area is empty. There is no collision wall and no forced teleport back inside. The server evaluates the same geometry for containment and damage. The tactical map draws the moving outline over its coarse cell overview. HUD guidance shows the time until the boundary reaches **your position**, the danger rate and a direction toward safety. Guidance to a nearby safe cell is not a street-level pathfinder; buildings and elevation can require a different route. The world edge uses moving native game VFX driven by that shared sweep. It replaces the old static post/concrete-wall description; the legacy renderer is suppressed while an authoritative sweep is present. The current work does not establish a uniformly legible 300 m boundary in every street or a 64-client GPU performance claim. See [VFX research](../docs/research/vfx-sfx-runtime.md) for actual asset, distance and budget measurements. `cordonEdge=world` enables world effects; `hud` removes them in favor of HUD feedback; `off` removes the optional edge effects. Core outside warnings and safe-direction guidance remain. These switches do not disable server damage. An unreadable position does not establish that a player is outside. ## Loot and consumables Cordon owns its inventory and validates pickups on the server. Containers have physical visuals and nearby world prompts; seeing a prop is not proof that a pickup or weapon assignment completed. | Equipment | Current behavior | |---|---| | Three weapon slots | Verified assignments populate the kit; the HUD identifies the actual active weapon and magazine/reserve ammunition. | | Armor plates | END starts the server's 3 s action. One plate adds up to 25 armor within the equipped shield's capacity. | | Healing items | F8 starts the server's 4 s action. Healing is applied on completion, not continuously during the bar. | | Inventory counts | Changed by authoritative completion, pickup and death handling, not by a key press. | Consumables require a living participant with the item. Damage interrupts the action. Full health, full armor, missing shield and empty inventory can refuse it. The progress bar starts only from the server's action state; FINISHING means completion has not yet been confirmed. One player cannot use a consumable and revive simultaneously. Weapon pickup success is reported after the assignment succeeds. The container stays claimed while the grant is pending. Explicit refusals known to have made no inventory change can restore it; an uncertain timeout does not blindly reopen it and duplicate a weapon. The player receives an uncertainty notice. The shooting arbiter accepts weapons through the verified inventory path. Operator `weapon.give` or an unrelated resource's loot command is not a substitute for a Cordon pickup. Death releases the mode's carried equipment according to its death-drop rules; successful revival returns the player without the old kit. Supply drops and other unused design tunables must not be interpreted as implemented world events. ## Squads and revival Duo and Squad assign teams at deployment. The current fill iterates queued players by id and fills available squads; party invite codes are not implemented. Do not promise that `/br.party` will group friends. The tactical map and squad panel carry squad-only positions and revive state. A downed squadmate has a default 30 s revive window while a living squadmate can answer it. The prompt is anchored to the captured three-dimensional body position; the dead player's current movement snapshot is not its source. Face the body and approach until its **F prompt is active**. The interaction must be on screen even though exact reticle focus is not required. Its 2.5 m activation range is measured in 3D to a prompt normally 1 m above the body, so 2.5 m horizontal separation can still be out of range. About 1.5 m on the same floor is comfortably inside. The server independently checks healer-to-body reach and squad identity. Tap F once to begin the 5 s action. Releasing the key does not cancel it; tap F again to cancel. Moving away or damage to either party also interrupts it. There is one target per healer. During native recovery confirmation, the HUD says WAITING FOR RECOVERY and additional taps cannot undo the submitted recovery. Only the canonical recovery acknowledgement restores alive state and credits the healer. Default revived health is 30%, with short spawn protection and no old kit. The complete squad phase gate also includes looting, fighting and extraction. A working revive alone does not close that broader gate. Ghost-flying spectators and an observer-visible authored revive animation are not established features; the supported spectator experience is staging watch. ## Bots, holdouts and unfinished world work Bot contestants use engine AI, occupy the match roster, are labelled **BOT**, and can win. Any match containing a contestant bot is unrateable. Default fill policy is `threshold`: below 24 humans, fill toward 40 contestants. `off` disables fill; `always` fills toward capacity. Bot movement requires a client simulation lease; a headless phantom is not an engine-driven fighting NPC. Gang holdouts are separate from contestant bots and spawn at high-tier loot clusters. They are disabled by default (`holdoutsEnabled=false`). Camp kills use **GANG GUARD** in the feed and do not affect rating. This opt-in policy does not change ordinary NPC behavior in other modes. Passenger-following AV flight and playable extraction flights are experimental. Boss templates, weather arcs and supply-drop declarations are not supported gameplay guarantees. ## Running a server Use the tracked [local profile](../server/server.cordon-local.jsonc) or [listed profile](../server/server.cordon-listed.jsonc), and inspect their resource lists and startup overrides. The mode lives in `resources/gamemodes/open77_cordon`; its HUD is `resources/gamemodes/open77_cordon_hud`. The setup template is `templates/cordon`, but older descriptive text there may describe prior design. The local profile uses game 11808, downloads 11809 and Warden 11810. These are local profile ports, not the production Cordon endpoint. Launch from `server/` after building with the repository's documented build procedure: ```powershell ./src/Open77.Server/bin/Release/net10.0/Open77.Server.exe --config server.cordon-local.jsonc ``` Keep the mode's declared dependencies and the profile's shared resources. [`open77_worldui`](https://github.com/Open2077/open77-resources/tree/main/system/open77_worldui) and [`open77_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_interactions) supply the physical revive/loot prompts; weapon and loot APIs underpin inventory; the HUD supplies match/combat/map views. Do not combine Cordon with a second gamemode that also owns spawn and death. Do not put laboratory resources or test control-file hooks on a public server. Use Warden tunables or commands for runtime changes. Editing a watched resource can reload the match; reconnect-policy client resources need a reconnect to receive the new pack. Test snapshots belong outside the live resource tree. Preserve the server's signing identity, database and environment during updates. ### Capacity and transport Set `network.maximumPlayers=64`. The tracked simulation configuration uses 30 Hz ticks, 20 Hz snapshots, a 500 m player-interest radius and 60 m hysteresis. Do not reduce interest below the supported combat range: an unseen remote player cannot be hit correctly by the client. Keep these transport environment settings for 64-slot operation: ```text OP77_GNS_SEND_RATE_BYTES=4194304 OP77_GNS_SEND_BUFFER_BYTES=8388608 ``` Interest culling saves bandwidth for spread players, but clustered rosters still need the increased per-connection send budget. The reproduced failure is `send_failed:LimitExceeded` in the client's `net.state`; the server can continue running and counting that session. Three final headless 64-session cycles completed with the continuous geometry and bounded state publisher. All 64 players were covered by state-send invocations in each cycle, including outside-state coverage. This measures server behavior; it does not prove client receipt, UDP/encryption capacity, 64 real HUDs or GPU performance. Historical measurements and harness limits are in [scale research](../docs/research/battle-royale-and-scale.md). ### Useful tunables Defaults below are declared source defaults. A profile or persisted override can replace them. A value reaching the resource immediately does not mean an already computed match schedule is rebuilt. | Key | Default | Purpose | |---|---|---| | `format` | `solo` | Next match: solo, duo or squad. | | `activeMap` | `watson` | Active surveyed/runtime map; Pacifica remains provisional. | | `minPlayers` |16 | Human queue threshold. | | `stagingSeconds` / `deploySeconds` |105 /45 | Queue and landing-selection windows. | | `drop` |false | Ground insertion by default. | | `schedule` |`standard` | `standard`, development `blitz`, or `long`; retired `short` falls back. | | `cordonSeconds` / `cordonGrace` / `cordonSteps` |0 | Zero uses the chosen preset; captured when the cordon arms. | | `damageScale` |1.0 | Live multiplier for zone damage and its HUD rate. | | `cordonEdge` |`world` | World effects, HUD fallback or optional effects off. | | `lootDensity` |1.0 | Scale the active map's loot counts, not a guaranteed 900 drops. | | `holdoutsEnabled` |false | Experimental camp spawner; keep current validation limits in mind. | | `botFill` / `botFillTarget` / `botFillFloor` |`threshold` /40 /24 | Contestant bot fill policy. | | `reviveWindowSeconds` |30 | Default opportunity to rescue a downed squadmate. | | `ratedMinPlayers` / `ratingK` |16 /32 | Rating admission and update size. | `gridSize` is a retained unimplemented tunable: actual grid geometry comes from the active map. Use `/br.map` for map authoring, not an unimplemented size knob. ### Commands and editor | Command | Use | |---|---| | `/br.status` | Match phase, roster, format, map, bot/rating and cordon state. | | `/br.where [playerId]` | Server-side position, state, squad, cell and kit; players inspect themselves, operators can inspect an id. | | `/br.queue` | Queue controls; use command help for operator/all-player forms. | | `/br.start`, `/br.end` | Restricted match controls. | | `/br.edge world\|hud\|off` | Inspect/change edge presentation through the supported command. | | `/br.map.check`, `/br.map` | Survey status and editor help. | | `/br.bots` | Restricted bot inspection/control; adding contestants taints rating. | | `/br.beside ` | Restricted placement beside a captured body for diagnosis. | | `/br.revive ` | Restricted direct server path; does not test physical F input. | | `/br.stats`, `/br.stats.of ` | Own career and restricted operator inspection. | Use the ACL-protected tac-map EDIT commands to record positions on walkable ground. Map data is stored outside the watched resource tree. Validate geometry with `/br.map.check` before deployment. ## Rating Progression is placement rating and career history; it grants no combat power. Ratings are format-specific. Under-populated matches and matches containing contestant bots do not update the ladder. Persistence is optional for play; without a database, history is volatile. Database availability alone does not mean a match was rated. ### Capturing spawn orientation `/br.map add spawn "Spawn"` captures the current character position **and body orientation** from the latest server snapshot. Turn the character toward the intended direction before capturing; rotating only the camera is insufficient. `/br.map add spawn 0 "Spawn"` explicitly overrides the heading with zero degrees. The visual editor's PLACE HERE action uses the same capture path. Vehicle and staging anchors also capture orientation when the heading is omitted: `/br.map add veh Vehicle.v_standard2_archer_hella_player` and `/br.map add stage`. `/br.map move spawn 1` and `/br.map move stage` recapture position and orientation; append an angle to override it. `/br.map paint spawn 6` captures the current heading at each point, while `/br.map paint spawn 6 0` keeps an explicit zero heading. Positions older than two seconds remain unavailable. Older hosts without snapshot yaw retain the existing "heading not captured" marker instead of claiming a capture. --- Source: https://open2077.net/docs/client-players # Players around you (client) `Open77.players` reads the identities, positions and distances of players rendered by the client. These client-side queries require no permission. ```lua resource "proximity_demo" version "1.0.0" client_script "client/main.lua" -- no permissions block: the enumeration below is ungated ``` ## Player IDs and entity handles **This is a streaming-scoped view, not the server roster.** It holds the players *this client* has been told about — the same roster that draws the nameplates — and nothing more. A player the server counts as connected can be missing from it for a long time: a different routing bucket, too far to stream, a proxy body still spawning, a world transition in progress. The list is also per-client, so two players standing together will not necessarily see the same one. So: | Question | Ask | |---|---| | Who is standing near me, and where is his body? | `Open77.players` on the client | | How many players are on this server? Is this id on the whitelist? Who gets the payout? | the server, over a [net callback](callbacks.md) | A resource that treats `#Open77.players.all()` as a head count writes a race that a quiet test server will never reproduce and a busy one will hit within the hour. The client cannot be made authoritative about a roster it only receives a slice of; the fix is always to ask the server. ## The API | Function | Answers | |---|---| | `Open77.players.all(options?)` | `{ playerId, ... }`, ascending, the local player included | | `Open77.players.localId()` | your own player id | | `Open77.players.entity(playerId)` | the entity id of that player's body | | `Open77.players.fromEntity(entityId)` | which player wears that body | | `Open77.players.nearby(radius, options?)` | everyone with a body within `radius`, nearest first | | `Open77.players.closest(options?)` | just the nearest one | Failures follow the repository convention: `nil, reason`, with stable snake_case tokens. The reason matters here more than usual, because "he has no body yet" and "I have never heard of him" are different facts and a proximity resource has to act differently on each. ### `localId()` — and why it is not `local()` `local` is a reserved word in Lua, so `Open77.players.local()` is a **syntax error**: the parser refuses the name before anything runs. The function is registered under both spellings, so ```lua Open77.players.localId() -- write this Open77.players["local"]() -- identical: the same function value ``` Both return your player id, or `nil, "no_session"` when you are not connected to a server. ### `all(options?)` ```lua for _, id in ipairs(Open77.players.all()) do print(id, id == Open77.players.localId() and "(me)" or "") end -- only the ones that actually have a body right now local visible = Open77.players.all({ streamed = true }) ``` Ascending by player id, so two calls in the same frame compare cleanly. **Your own id is in the list**, matching FiveM's `GetActivePlayers`; filter it out yourself if you do not want it. A player who is connected but has no body — his proxy has not spawned, or it was retired for a death, a re-dress or a world transition — **stays in the list** with no entity. Dropping him would make "not streamed yet" indistinguishable from "left the server", which is exactly the distinction this API exists to preserve. `{ streamed = true }` is how you ask for only the bodied ones; it is the equivalent of FiveM's `NetworkIsPlayerActive` filter. A player who leaves is gone from the list immediately, and so is the id of a body that was retired: see [Ids do not outlive bodies](#ids-do-not-outlive-bodies). ### `entity(playerId)` and `fromEntity(entityId)` The two directions of the mapping. Feed the entity into any Open77 entity API — `Open77.character.state(entity)` first among them — for the transform, stance, weapon and vehicle. For the same body's own axes — a point two metres in front of it, or where its head is — see [an entity's own axes](world-queries.md#an-entitys-own-axes). ```lua local id = Open77.players.localId() local entity = Open77.players.entity(id) local state = Open77.character.state(entity) print(("%s is at %.1f, %.1f"):format(id, state.position.x, state.position.y)) ``` `entity()` accepts the player id as a number or as a decimal string. It returns: | Result | Meaning | |---|---| | an entity id | that player has a body, and this addresses it | | `nil, "player_not_streamed"` | he is here, he has no body — do not retry in a loop, wait for him | | `nil, "unknown_player"` | this client has never heard of that id | | `nil, "invalid_player"` | the argument was not an id at all | **Your own body is the entity id `0`.** That is not a failure value: `0` is the reserved id that every Open77 entity API reads as "the local player", which is why `Open77.character.state()` with no argument and `Open77.character.state(Open77.players.entity(Open77.players.localId()))` are the same call. `fromEntity(0)` gives your player id back. `fromEntity()` takes the entity as a number or a decimal string — the string form is what a raycast hit or the older `Open77.animations._context()` map hands out — and answers `nil, "unknown_entity"` for anything that is not a live player body right now. ### `nearby(radius, options?)` and `closest(options?)` Centred on your own body, sorted by ascending distance, ties broken by player id so that "closest" does not flicker between two bodies standing on the same spot. ```lua for _, player in ipairs(Open77.players.nearby(15.0)) do print(("%s at %.1f m"):format(player.name, player.distance)) end local target = Open77.players.closest({ radius = 3.0 }) if target then print(("you are next to %s"):format(target.name)) end ``` Each entry is: | Field | | |---|---| | `playerId` | the network id | | `entity` | the body, ready for `Open77.character.state` | | `distance` | metres from the origin | | `position` | world position — a plain `{ x, y, z }` table, which **is** a [vector3](vectors.md) | | `name` | display name | | `isLocal` | true only for your own entry, and only if you asked for it | Options, shared by both: | Option | Default | | |---|---|---| | `origin` | your own body | measure from somewhere else — a raycast hit, a door, a marker | | `includeSelf` | `false` | put your own entry in the result | | `limit` | unlimited | `nearby` only, 1..256, applied after sorting | | `radius` | unlimited | `closest` only; `nearby` takes it as its first argument | Because `position` is a plain x/y/z table, it is already a vector everywhere a vector is accepted: ```lua local a, b = Open77.players.nearby(50)[1], Open77.players.nearby(50)[2] print(#(vector3(a.position) - vector3(b.position))) -- metres between them ``` Failure reasons: | Reason | Meaning | |---|---| | `no_session` | not connected to a server | | `no_local_position` | you are connected but have no body — the "continue" screen, or a world transition — so there is no centre to measure from. Pass `origin` to ask anyway | | `no_player_in_range` | `closest` only: nobody qualified. `nearby` returns an empty table instead | | `invalid_radius` / `invalid_origin` / `invalid_limit` | the argument was malformed | Players without a body are skipped by both: they have no position to sort by. Use `all()` if you need to know they exist. ## Ids do not outlive bodies A proxy body is destroyed and recreated more often than players expect — on death, on a clothing change, on a world transition. When it goes, the entity id goes with it: ```lua local entity = Open77.players.entity(otherPlayer) -- 12884901889 -- ... he dies and respawns ... Open77.players.fromEntity(entity) --> nil, "unknown_entity" Open77.players.entity(otherPlayer) --> a different id ``` **Cache the player id, never the entity id.** The player id is stable for the whole session; the entity id is a handle on one particular body. Resolving it again is one table lookup, so there is nothing to gain by holding it. ## Permission None. The roster is what the client is already rendering. The two things a roster could leak that a screenshot cannot — life state and stats — are not here: `Open77.players.getLifeState`, `isDead`, `allLifeStates`, `getHealthState` and `allHealthStates` stay behind `players.life.read` in the same table, and the stats reads behind `players.stats.read`. Asking who is standing in front of you does not grant you their health bar. ## Relation to the server API The dedicated server has its own `Open77.players` with the same names and different authority — its roster is the real one, and it can act on it. See [the server API](server-api.md). A resource that needs a truth the client cannot have asks for it over a [net callback](callbacks.md): ```lua -- server: the authoritative answer Open77.net.register("population", function(source) return #Open77.players.all() end) -- client, inside a CreateThread, with `network.events` in the manifest local count = Open77.net.callAwait("population") ``` --- Source: https://open2077.net/docs/player-utilities # Player checks and controls Read native player state and apply client-local controls. Player IDs are network IDs, not entity handles. Omit the ID to target the local player. Failed reads return `nil, reason`; failed controls return `false, reason`. The original checks and controls are available in client **2.31.13+op77.62**, protocol **1.25**. These are client APIs; server resources request their own client's actions through permissioned events. ```lua permissions { 'players.read', 'players.controls' } ``` ## Local avatar visibility [Local player visibility](local-puppet-visibility.md) explains `Open77.players.setLocalPuppetVisible(visible)` and `Open77.players.isLocalPuppetVisible()`, including the `players.local.visibility` permission and resource cleanup. Hide the native body and third-person double while keeping the selected camera, for example in Prop Hunt. These functions require a compatible client independently of the utility version listed above. ## Passenger drive-by [Passenger drive-by](drive-by.md) covers `setLocalDriveByEnabled`, `getDriveByState`, `isDriveByEnabled` and `isInDriveBy`, plus the server policy `setDriveByEnabled`. It includes client/server examples, permissions, phases and cleanup. Use the Unstable `.117` client and server, protocol 1.42. ## Read-only checks Every global below also exists under `Open77.players` with the indicated method. | Global | Method | Meaning | | --- | --- | --- | | `IsPlayerSwimming([playerId])` | `isSwimming` | Native surface swimming or diving | | `IsPlayerDiving([playerId])` | `isDiving` | Native underwater swimming state | | `IsPlayerAiming([playerId])` | `isAiming` | Graph-produced aim state, including toggle aim | | `IsPlayerShooting([playerId])` | `isShooting` | Graph trigger state; not confirmation of damage or every projectile | | `IsPlayerReloading([playerId])` | `isReloading` | Reload state | | `IsPlayerGrounded([playerId])` | `isGrounded` | Movement ground contact | | `IsPlayerCrouching([playerId])` | `isCrouching` | Crouched locomotion | | `IsPlayerSliding([playerId])` | `isSliding` | Sliding locomotion | | `IsPlayerSprinting([playerId])` | `isSprinting` | Sprint state, not a velocity threshold | | `IsPlayerVaulting([playerId])` | `isVaulting` | Vault state | | `IsPlayerJumping([playerId])` | `isJumping` | Jump/double-jump phase | | `IsPlayerFalling([playerId])` | `isFalling` | Falling phase | | `IsPlayerInVehicle([playerId])` | `isInVehicle` | Native mounted state | | `IsPlayerDriver([playerId])` | `isDriver` | Mounted in the driver's seat | | `IsPlayerPassenger([playerId])` | `isPassenger` | Mounted in a non-driver seat | Remote checks require a streamed, presentation-ready player and return `nil, 'player_not_streamed'` otherwise. Swimming/diving are currently local-only: their PSM field is not replicated, so querying a remote player returns `nil, 'state_not_replicated'` rather than inventing a false result. Remote values are presentation snapshots and must **not** authorize server-side rewards/damage. ```lua local swimming, reason = IsPlayerSwimming() if swimming == nil then return end -- unavailable is not the same as false if swimming then Open77.log.info('Player is swimming') end ``` ## Resource-owned controls These client calls affect the local player. Server scripts can signal their own client resource using the normal permissioned event API. They are gameplay controls, not server-side anti-cheat enforcement. | Global / `Open77.players` method | Argument | Block bit | | --- | --- | --- | | `FreezePosition` / `freezePosition` | `true` requests native forced-freeze locomotion | 1 | | `FreezeRotation` / `freezeRotation` | `true` applies native camera/turn restriction | 2 | | `AllowJump` / `allowJump` | `false` blocks jumping | 4 | | `AllowAim` / `allowAim` | `false` blocks aim entry and exits held aim | 8 | | `AllowRunning` / `allowRunning` | `false` requests native forced walking | 16 | | `AllowShoot` / `allowShoot` | `false` blocks firearm firing decisions, including an ongoing automatic firing sequence | 32 | | `AllowCrouch` / `allowCrouch` | `false` requests native standing | 64 | | `AllowDodge` / `allowDodge` | `false` blocks ground/air dodge entry | 128 | | `AllowWeapons` / `allowWeapons` | `false` applies the native no-weapons restriction | 256 | | `AllowInteraction` / `allowInteraction` | `false` blocks native world interactions | 512 | Only actual Lua booleans are accepted: the string `'false'` is rejected. `allow=true` releases **this resource's** block; it cannot override another resource or a vanilla restriction. `freeze=false` follows the same ownership rule. `Open77.players.resetControls()` releases every block owned by the caller. `Open77.players.getControlMask()` returns the combined Open77 block bitmask; it is not an inventory of vanilla restrictions. All require `players.controls`. ```lua assert(AllowJump(false)) assert(AllowShoot(false)) -- When this activity ends: assert(Open77.players.resetControls()) ``` The host releases blocks on resource stop, session teardown, death and player-body replacement. The native query has a two-second watchdog so abandoned runtime state does not leave a player locked indefinitely. These controls do not freeze a vehicle, stop a moving platform, cancel server teleports, or cancel projectiles already fired. ## Compatibility limits Movement controls restrict native locomotion and input; they are not physics anchors. Airborne, moving-platform and traversal behavior can differ. Combat checks return `nil, 'state_unavailable'` until a graph sample exists. Swimming and diving use native state and do not synthesize missing remote-player data. `IsPointOnRoad` is **not implemented**. The exposed road/lane natives inspected so far query the current player/vehicle; they do not classify arbitrary world coordinates. An asphalt material or pedestrian navmesh hit would not be a correct replacement. --- Source: https://open2077.net/docs/local-puppet-visibility # Local player visibility Client Lua can hide the avatar seen on the player's own screen, including the third-person double, with `Open77.players.setLocalPuppetVisible(visible)`. Requires a client exposing both local-puppet visibility functions. Treat the API as experimental and check that the functions exist on older clients. The server visibility API is separate; no new server visibility call is required. ```lua -- open77.lua permissions { "players.local.visibility", "network.events" } -- client.lua RegisterNetEvent("disguise:visible", function(visible) local setVisible = Open77.players.setLocalPuppetVisible if not setVisible then print("Local avatar visibility is unavailable on this client") return end local ok, reason = setVisible(visible) if not ok then print(reason) end end) -- Your server sends false when the disguise starts and true when it ends. ``` The setter requires one strict boolean and returns `true` for an accepted policy, or `false, reason`. Rendering follows on the game tick. The getter takes no arguments and returns the combined resource policy, or `nil, reason`. `true` means that no resource holds a hide; it does not force the camera into third person or guarantee that a body is currently on screen. Each resource instance owns its request. Calling `true`, stopping or reloading a resource releases only its own hide. If another resource still requests `false`, the avatar stays hidden. Death, player replacement and disconnect release the local lifecycle's requests; reapply the disguise from your spawn logic if appropriate. Initial script loading cannot mutate this state; use a running callback or thread. The native body, its held weapon and the third-person presentation are hidden locally. The camera keeps its selected view and can switch FPP/TPP while the hide is held. Movement and collision remain unchanged. This does not remove gameplay effects or separately spawned props. It does not change what other players see: server `Open77.players.setVisible(playerId, visible)` controls that independently. ## Prop Hunt example ```lua -- server.lua: add permission "players.life.visibility" to the manifest. -- Call only after the server has validated and applied the disguise. local function setDisguised(playerId, disguised) local ok, reason = Open77.players.setVisible(playerId, not disguised) if not ok then return false, reason end TriggerClientEvent("prophunt:localDisguise", playerId, disguised) return true end -- client.lua RegisterNetEvent("prophunt:localDisguise", function(disguised) local setVisible = Open77.players.setLocalPuppetVisible if not setVisible then print("This client needs the local-puppet visibility update") return end local ok, reason = setVisible(not disguised) if not ok then print("Local disguise refused: " .. tostring(reason)) end end) ``` Use the server's own disguise state to attach/show the prop. The local call does not replicate a disguise or grant invisibility to other clients. A successful server visibility change does not guarantee that an outdated client supports the local call; enforce your minimum client version for the mode. Errors include `expected_boolean`, `unexpected_arguments`, `permission_denied:players.local.visibility`, `resource_not_running`, `player_not_ready`, `visibility_owner_quota` and `game_unavailable_on_this_host`. An owner may always release its existing request even while no player is ready. ## Function reference See [setLocalPuppetVisible](/docs/api/client/open77-players#setlocalpuppetvisible) and [isLocalPuppetVisible](/docs/api/client/open77-players#islocalpuppetvisible). Custom bodies and vehicle cameras may require additional mode-specific checks. --- Source: https://open2077.net/docs/player-stats # Player health and stamina The server owns player health, stamina, regeneration and maximum values. One versioned state per connected player is replicated to clients; resources do not need a separate synchronization service. The two runtimes deliberately expose the same read API. Mutations exist only on the server. ## Permissions ```lua permission "players.stats.read" -- client and server reads permission "players.stats.apply" -- server mutations only ``` The older `players.damage.read` / `players.damage.apply` permissions remain accepted for compatibility, and `players.life.read` may also read stats. New resources should use `players.stats.*` and request `combat.config` separately when they also change PvP policy. ## Shared read API These functions have the same names and return shape in client and server Lua: ```lua local stats = Open77.stats.get(playerId) local health = Open77.stats.getHealth(playerId) -- alias: health local stamina = Open77.stats.getStamina(playerId) -- alias: stamina ``` On the client, `playerId` may be omitted to read the local player. The server always requires it because a server resource has no implicit local player. Unknown or not-yet-replicated players return `nil`. `get` returns: ```lua { playerId = 7, revision = 42, staminaAuthorityRevision = 9, armor = 0, godMode = false, lastAttacker = 12, lastDamage = 25, lastAttackKind = "ranged", lastBodyPart = "torso", health = { pool = "health", value = 75, current = 75, maximum = 150, max = 150, fraction = 0.5, percentage = 50, regenEnabled = true, regenPerSecond = 5, regenerating = true }, stamina = { pool = "stamina", value = 60, current = 60, maximum = 120, max = 120, fraction = 0.5, percentage = 50, regenEnabled = true, regenPerSecond = 20, regenerating = false } } ``` All values and regeneration rates are points, not percentages. `revision` changes whenever any canonical stat or its configuration changes. ## Server mutation API Every mutation returns `true`, or `false, reason`. | Function | Purpose | |---|---| | `Open77.stats.set(playerId, pool, value)` | Set `"health"` or `"stamina"` in points. | | `Open77.stats.setMax(playerId, pool, maximum)` | Set the canonical maximum and clamp the current value. | | `Open77.stats.restore(playerId, pool)` | Fill the selected pool to its maximum. | | `Open77.stats.setRegenEnabled(playerId, pool, enabled)` | Enable or disable server regeneration. | | `Open77.stats.setRegenRate(playerId, pool, pointsPerSecond)` | Set its authoritative rate; zero disables it. | | `setHealth`, `setHealthMax`, `restoreHealth` | Explicit health aliases. | | `setHealthRegenEnabled`, `setHealthRegenRate` | Explicit health-regeneration aliases. | | `setStamina`, `setStaminaMax`, `restoreStamina` | Explicit stamina aliases. | | `setStaminaRegenEnabled`, `setStaminaRegenRate` | Explicit stamina-regeneration aliases. | Example: ```lua local ok, reason = Open77.stats.setHealthMax(playerId, 200) if not ok then return print(reason) end assert(Open77.stats.setHealth(playerId, 200)) assert(Open77.stats.setHealthRegenRate(playerId, 8)) assert(Open77.stats.setHealthRegenEnabled(playerId, true)) assert(Open77.stats.setStaminaMax(playerId, 160)) assert(Open77.stats.setStamina(playerId, 80)) assert(Open77.stats.setStaminaRegenRate(playerId, 30)) ``` Setting health to zero uses the player-life authority and therefore performs a real attributed soft death rather than merely writing a native pool. Direct damage, healing, armor and god mode remain available through the compatible `Open77.players.damage`, `heal`, `setArmor`, and `setGodMode` methods. ## Fall damage ```lua permission "players.life.falldamage" -- server: switch a player's fall damage off and on permission "players.life.read" -- read it back ``` | Function | Purpose | |---|---| | `Open77.players.setFallDamage(playerId, enabled)` | `false` makes landings harmless for this player; `true` restores the vanilla behaviour. `true`, or `false, reason`. | | `Open77.players.isFallDamageEnabled(playerId)` | Whether landings still hurt him. `Open77.players.get(playerId).fallDamage` carries the same bit. | **Fall damage only.** This is FiveM's fall-damage toggle and nothing wider: a player whose fall damage is off is still shot, stabbed, burned, blown up and run over exactly as before, and still dies from all of it. It is not `setGodMode` (the combat ledger's damage immunity) and it is not the grav-chute's shield (`Open77.chute`, a blanket *Invulnerable* god mode that exists because the chute needs *everything* off for the seconds it is armed). A FiveM script that reaches for `SetEntityInvincible` wants `setGodMode`; one that reaches for the fall-damage toggle wants this. **Why it is a life flag and not a stat.** On 2.31 a hard landing is not a number the engine subtracts from a pool. The player's own locomotion state machine grades the landing from its vertical speed and, for the three grades that hurt, fires a self-targeted area attack -- `Attacks.HardLanding`, then `VeryHardLanding`, then `DeathLanding` -- through the ordinary damage pipeline, on the landing client only. So the only place fall damage can be refused is that client, inside the damage pipeline, and the only honest way to refuse *fall* damage rather than *all* damage is to recognise those three records and nothing else. That is what the client does while the server's bit is up: the three landing records aimed at its own body are dropped before they are priced; every other hit is untouched; and the stumble, the camera hit and the grunt still play, because they come from the locomotion state and a player told "no fall damage" still expects to feel the fall. Observers need do nothing -- a landing is resolved only on the body that landed. **Release paths.** The bit rides the replicated life state, so it survives a proxy re-stream and travels with the player across a bucket change (it concerns his own client, not the observers he left). Death drops it (a dead body takes no landings, and the respawn starts vanilla unless asked otherwise), a reconnect drops it, and the resource that switched it off switches it back on when it stops, reloads or crashes -- nothing else could, because there is no proximity notion to auto-clear against. Refused with `transition_in_progress` during a revive or respawn, like every other life bit; **allowed while dead**, so a gamemode that drops players from the sky can shield them before the respawn it is about to issue rather than racing the first landing: ```lua -- A parkour round: nobody dies to the course, everybody still dies to each other. AddEventHandler("parkour:roundStart", function() for _, id in ipairs(Open77.players.all()) do Open77.players.setFallDamage(id, false) end end) -- Re-arm on death: Kill zeroed the bit, and the spawn point is a rooftop. AddEventHandler("onPlayerLifeStateChanged", function(playerId, revision, phase) if phase ~= "dead" then return end Open77.players.setFallDamage(playerId, false) -- allowed while dead; rides the respawn Open77.players.respawn(playerId, { position = spawns.rooftop, heading = 90 }) end) AddEventHandler("parkour:roundEnd", function() for _, id in ipairs(Open77.players.all()) do Open77.players.setFallDamage(id, true) end end) ``` On the wire the flag is `PlayerLifeFlags.FallShield` (`1 << 3`), beside `Ghosted` (`1 << 0`), `Frozen` (`1 << 1`) and `Invisible` (`1 << 2`). The flags field has always been serialized and never validated, so assigning the bit is not a protocol change: a client that predates it decodes the value and ignores the bit, which degrades to "vanilla fall damage" rather than a disconnect. ## Synchronization model The server sends health, stamina, maximums, regeneration configuration and the combined revision in one reliable `PlayerHealthState` message. Late join and routing-bucket changes replay the same durable state. The native client also keeps the newest snapshot across REDengine world reloads and reapplies it when the new local player/stat-pool systems become available. Cyberpunk consumes stamina locally while sprinting, dodging or attacking. The owning client samples that native decrease and sends only the bounded positive delta consumed. The server accepts only monotonic sequences and the current authority revision, then subtracts the delta from its current canonical value. It never accepts a client-side refill or maximum change. Consumption deltas and server regeneration commute, so neither can erase the other under latency. Explicit stamina writes, maximum changes and life transitions advance the authority revision, making older in-flight observations harmless. Changing a regeneration rate or toggle keeps the epoch because it commutes with pending consumption too. Both native Health and Stamina regeneration are disabled during a multiplayer session. Server ticks are the only regeneration source, and clients project the canonical values back onto the engine pools. Disconnecting removes Open77's temporary maximum and regeneration overrides. ## Events ```lua AddEventHandler("open77:playerStatsChanged", function( playerId, health, maxHealth, stamina, maxStamina, revision, reason ) end) ``` The existing `open77:playerHealthChanged` and damage-feedback events remain available for compatibility. Use the new event when a system needs to react to either pool or to configuration-driven revisions. ### Damage feedback events (client) Three client events carry the server's damage verdict to Lua — raised from the authoritative `PlayerHealthState` broadcast, never from the shooter's local raycast, so a hitmarker drawn from `hitConfirmed` is a hit the ledger credited. They are the counterpart of FiveM's `gameEventTriggered` / `CEventNetworkEntityDamage`. Arguments are scalar **strings**, like every engine-raised event. ```lua -- The local player lost health: direction and amount for a damage indicator. -- Raised on the VICTIM's client only. AddEventHandler("open77:localDamaged", function(attackerId, amount, dirX, dirY, dirZ, bodyPart, health, maxHealth) end) -- The server credited a hit by the local player on a PLAYER: the authoritative -- hitmarker. Raised on the ATTACKER's client only. `lethal` is "1" or "0". AddEventHandler("open77:hitConfirmed", function(victimId, amount, bodyPart, lethal) end) -- Any player whose proxy this client has took damage: nameplate bars, kill feeds. AddEventHandler("open77:playerDamaged", function(victimId, attackerId, amount, bodyPart, health, maxHealth) end) ``` Two traps. **`hitConfirmed` is player-victim only** — it is raised from the player-health drain, so a hit on a server-owned NPC produces no hitmarker; the shooter's client gets `open77:npcHit(npcId, damage, hitZ, weaponTdbId, attackKind)` for that. And **the server event of the same name has a different list**: server-side `open77:playerDamaged` carries `(victimId, attackerId, amount, attackKind, weaponTdbId, bodyPart, remainingHealth, maxHealth, lethal, downedHit)`, so a handler copied across reads the wrong column. --- Source: https://open2077.net/docs/player-freeze # Freezing a player Freeze a player's position with a server-authoritative state or a client-local claim. Use this for restraints, cutscenes and interfaces that require the player to remain stationary. There are two halves, and they are not alternatives: | | Call | Permission | Who decides | |---|---|---|---| | **Server** | `Open77.players.setFrozen(playerId, frozen)` | `players.life.freeze` | Authoritative. Rides the replicated life state, every observer agrees. | | **Client** | `Open77.character.setFrozen(frozen)` | `player.freeze` | Presentation-local. Your body, your machine, no round trip. | The server's freeze outranks the local one. Releasing a local hold while the server holds the player changes nothing, and the local call says so in its second return value. ## The mechanism Both halves apply the same thing: `GameplayRestriction.NoMovement`, the engine's own restriction and the very record vanilla `GroundDeathEvents` applies on death. That matters for three reasons. - **Nothing fights the hold.** The locomotion state machine *polls* the restriction, so there is no per-frame contest with an input path, and no animation plays. Suspending the mover component instead would leave the body sliding, which is why `Movement.hpp` warns against it. - **Nothing slides on anybody else's screen.** A frozen player keeps sending snapshots at the normal rate; their position simply stops changing. The interpolator already refuses to extrapolate past 0.25 s, so no remote flag is needed and none was added. - **It composes.** Open77 owns exactly one stack of the restriction and asks exactly one question to decide whether to hold it: soft death **or** the server freeze **or** any local claim. A restriction that was already standing before Open77 wanted one is never ours and is never removed. ## What a freeze stops, and what it deliberately does not This list is the usability of the feature. Get it wrong and a frozen player is either still walking or unable to say why he is stuck. | Still works | Stopped | |---|---| | Looking around -- mouse and controller camera | Walking, running, sprinting, crouch-moving | | Chat | Jumping, dodging | | Voice | Sliding, vaulting | | Opening menus and the inventory | Every other locomotion input, pressed or held | | Interaction prompts | | | **Taking damage, and dying** | | The precedent is the death restriction, which has spared chat and voice since it shipped: a player who cannot move must still be able to say so. A freeze does **not** holster a weapon, block firing, or eject anyone from a vehicle. It is a primitive, not a policy -- compose it with the control-blocking surface when a scene needs more. ## A hold is a claim, not a value Both halves are keyed by the calling resource. A jail script and a cutscene script may hold the same player at once, and `setFrozen(false)` drops only your own claim: the player stays frozen while anybody else still holds him. `isFrozen` answers for all holders, not just yours. ```lua -- client, permission player.freeze local ok, stillHeld = Open77.character.setFrozen(true) -- ... show the menu ... local ok, stillHeld = Open77.character.setFrozen(false) if stillHeld then -- somebody else -- or the server -- is still holding him. Not our business. end ``` ## Nothing can strand a player A freeze whose owner is gone would be unrecoverable, so every path that could produce one releases it: | Path | What happens | |---|---| | The resource stops, reloads or crashes | The host drops that resource's claims, on both sides | | The player dies | Server claims and local claims are both dropped | | The player respawns | The flag is cleared again, belt and braces | | The player changes routing bucket | The claims were about a body in another world | | The player disconnects | Server claims dropped; a reconnecting session starts free | | The session ends on the client | Every local claim is dropped | Because death and respawn clear it outright, a gamemode that wants a player held *through* a respawn must re-apply the freeze afterwards. The server call is refused with `transition_in_progress` while a revive or respawn is in flight, exactly like `setGhosted` -- the revision the client is acking must not move under it. A resource *stop* during one still thaws: it corrects the state without publishing, and the transition's own next publish carries the corrected flag. A refusal there would be precisely the stranding this guards against. ## Putting a player down, and clearing everything: ragdoll and clearTasks Two more body primitives live beside the freeze, both server-side and both composed from services that already ship rather than built new. | Function | Permission | Purpose | |---|---|---| | `Open77.players.ragdoll(playerId, options?)` | `players.motion.control` | Put the body on the ground for a bounded time; it gets up on its own. FiveM's `SetPedToRagdoll`. | | `Open77.players.clearTasks(playerId)` | any of `players.motion.control`, `players.animations.control`, `players.life.freeze` | End everything **this resource** put on the body -- its ragdoll, its animation, its freeze. FiveM's `ClearPedTasksImmediately`. | ### A ragdoll is the knockdown, with a clock `Open77.motion.knockdown` already puts a living player on the ground through the engine's own knockdown status -- the record vanilla uses, a physical fall the observers replicate as a hit reaction. What it demanded from every caller was a direction and a distance, and what it lacked was a duration. `ragdoll` supplies the defaults and the clock: ```lua Open77.players.ragdoll(id) -- 3 s, in place, facing forward Open77.players.ragdoll(id, { durationMs = 1200 }) -- up again after 1.2 s Open77.players.ragdoll(id, { direction = 90 }) -- a heading in degrees (0 = north) Open77.players.ragdoll(id, { direction = { x = 0, y = 1 }, distance = 2 }) -- a 2 m shove ``` - `direction` defaults to the body's own facing, read from the server's last accepted transform (`Open77.players.get(id).heading`); a player who has never reported one falls "north". With the default `distance` of `0` the direction only decides the way the body faces as it goes down -- it is never a displacement. - `durationMs` counts from the moment the owning client reports the body down, so "1200" is 1.2 s *on the ground*, not 1.2 s from the request. It is clamped to the engine's own knockdown -- **3000 ms** -- because the native status the client applies removes itself after three seconds and a longer promise could not be kept. The effective number comes back as `durationMs`. - The result is the knockdown's: `{ ok = true, id = ..., durationMs = ... }`, or `nil, reason`. `Open77.motion.cancel(id, result.id)` stands the body up early, and `onPlayerMotionChanged(playerId, id, phase, reason)` reports `pending`, `active` and `ended` -- `reason = "duration_elapsed"` is the clock. - Refusals are the knockdown's too: `motion_busy` while a motion is already on the body (including the 1.5 s recovery window after one ends, so hit spam cannot chain ragdolls), `body_unavailable` for a player who is not alive, on foot and ready, `invalid_direction` / `invalid_duration` for bad arguments — and `motion_unavailable` when the motion service is unavailable. Motion leases do not require the implant database. The server still requires a ready canonical body, an alive life state, an open readiness session and matching incarnation and routing bucket. **Physics ragdoll is not exposed to Lua.** Scripted knockdowns use an alive-only motion lease, separate from the death and respawn ragdoll path. The lease ends as soon as the player leaves the `alive` phase. ```lua -- A stun grenade: everybody within 6 m goes down for two seconds. RegisterNetEvent("stun:detonated", function(x, y, z) for _, id in ipairs(Open77.players.nearby({ x = x, y = y, z = z }, 6.0)) do local read = Open77.players.get(id) if read and read.alive and not read.inVehicle then Open77.players.ragdoll(id, { durationMs = 2000 }) end end end) ``` ### clearTasks releases what this resource holds, and only that Three services keep per-owner claims -- the motion lease, the animation playback, the freeze -- and each already refuses to release another owner's. `clearTasks` asks all three in one call and reports per half: ```lua local result, reason = Open77.players.clearTasks(id) -- result = { motion = "cancelled", animation = "stopped", freeze = "released", clear = true } ``` Each half answers `cancelled` / `stopped` / `released` when something of yours ended, `none` when you held nothing, `not_owned` when another resource holds it (and it is left alone -- a jail script's freeze does not end because a cutscene script cleaned up), or the owning service's own reason when it refused (`transition_in_progress` on a freeze during a revive). `clear` is `true` when nothing of yours is left on the body. `nil, reason` only for a bad id or a resource holding none of the three capabilities -- then nothing could have been put on the player in the first place. The animation half covers every playback shape the animation service owns for that player: an emote, a placed posture (`playAt`), a raw clip and a sequence. Everything it releases is something the server already releases when your resource stops; the call simply lets a script do it on purpose, mid-life. ```lua -- /free : whatever this resource was doing to them, stop it. RegisterCommand("free", function(source, args) local target = tonumber(args[1]) if not target then return end local result = Open77.players.clearTasks(target) if result and not result.clear then print(("still held: freeze=%s"):format(result.freeze)) end end, true) ``` ## On the wire The flag is `PlayerLifeFlags.Frozen` (`1 << 1`) on the canonical life state, beside `Ghosted` (`1 << 0`). The life-state flags field has always been serialized and never validated, so defining a bit is not a protocol change: a client that predates it decodes the value and ignores the bit, which degrades to "no freeze" rather than a disconnect. Read it as `Open77.players.getLifeState(playerId).frozen`. ## See also - [Complete server Lua API](server-api.md) -- the `Open77.players.setFrozen` row and the permission summary. - [Player health and stamina](player-stats.md) -- a frozen player still takes damage, and the fall-damage toggle that lives beside these body primitives. --- Source: https://open2077.net/docs/travel # Travel Move players with the server-owned travel APIs. Use teleportation and placement operations that coordinate client streaming instead of writing transforms directly. Everything on `Open77.travel` is client-side and requires the `player.travel` permission. That permission belongs only in operator-audited resources: the native lab commands these replace are refused while a multiplayer session is active, so an ACL-checked server command asking a trusted client resource to call them is the intended path. If the **server** is the one deciding where somebody goes, do not reach for this page — use [`Open77.players.teleport`](server-api.md#moving-a-player), which drives exactly the machinery described below and answers the server with the result. ## The two teleports, and why there are two ```lua Open77.travel.teleport(x, y, z, heading) -- fire and forget Open77.travel.teleportAndSettle({ x = x, y = y, z = z }, heading) -- Promise ``` `Open77.travel.teleport` queues one engine request and returns. It checks a finite transform and a live player, and that is all it knows. In particular **it knows nothing about streaming**, and that gap is not academic: - The engine executes the teleport on a later tick, so a position sampled in the frame that asked for it is the old one. - If the floor at the destination has not streamed in yet, the body falls through it. - Cyberpunk's own `Failsafe/PlayerTeleportationIfFallsUnderWorld` then returns the player to the last place the engine considers safe — the save's spawn of record, which can be kilometres away — and nothing tells the caller. Use the travel APIs' streaming and settle checks for distant destinations. Repeating a raw transform write does not make an unloaded destination safe. `Open77.travel.teleportAndSettle` is the same teleport with the answer attached. ```lua CreateThread(function() local arrival, reason = Open77.travel.teleportAndSettle( { x = 1669.75, y = -739.12, z = 49.86 }, 180):await() if not arrival then print("did not arrive: " .. tostring(reason)) return end print(("%s after %d attempts, %d pins, %d ms") :format(arrival.state, arrival.attempts, arrival.pins, arrival.elapsedMs)) end) ``` It resolves with `{ state, x, y, z, attempts, pins, elapsedMs }`: | `state` | What it means | |---|---| | `settled` | The body was on the point, grounded, and in no fall state for **three consecutive frames**. This is the answer. | | `near` | The 1.5 s window closed with the body on the point but never reported grounded — a mark on a prop, or a very slow stream-in. An honest success with a weaker claim. | and rejects with a stable reason: | Reason | Meaning | |---|---| | `settle_timeout` | The body never stood at the destination inside the budget (default 12 s, `options.timeoutMs` is 1000..30000). | | `settle_superseded` | Another `teleportAndSettle` started before this one finished. There is one body, so there is one session. | | `settle_cancelled` | `Open77.travel.cancelSettle` stopped the watch. The body stays wherever it currently is. | | `player_unavailable`, `teleport_unavailable`, `invalid_transform` | The teleport itself was refused; the watch never started. | ## What "settled" is actually testing **There is no streaming query anywhere in the engine surface Open77 can reach.** `StreamingSector`, `worldStreaming`, `IsStreamed`, `RequestStreaming`, `StreamingWorld` and `worldRuntimeScene` were all swept for; none of them is callable. So "is the destination streamed?" is not a question that can be asked, and the API deliberately does not pretend otherwise. What can be observed is the body, and that turns out to be the question a caller really has: *can I let go of this yet?* The watch asserts three things together, every frame: 1. **on the point** — within 4 m horizontally and 6 m vertically of the mark; 2. **grounded** — the player state machine says so; 3. **not falling** — the fall state is `None`. Not "not a lethal fall": the second production trace read `fall=Safe` while the body sank through an unloaded floor. All three, for three consecutive frames. A single near sample proves nothing; the trap showed the body at the wrong point twice before reading `grounded=yes`. When the body is on the point but sinking — not grounded, and more than 0.75 m below the mark — the teleport is **re-issued every 250 ms** for up to 7 s. That resets the fall before the failsafe can fire and keeps the engine's streaming prefetch aimed at the mark, until the floor arrives. `pins` in the result counts how many times that happened; a non-zero `pins` means the destination was not resident when you asked for it. If the body ends up somewhere else entirely when the window closes, the whole placement is re-issued, up to the session's timeout. The same watch drives the respawn placement in `LifeReplication` — one implementation, one set of constants, both paid for by the same two incidents. ## Raw settle tickets `teleportAndSettle` is a thin wrapper over three natives, which are there for a caller who wants to drive the poll itself: ```lua local ticket, reason = Open77.travel.beginSettle(x, y, z, heading, timeoutMs) local status = Open77.travel.settleStatus(ticket) -- state, reason, x, y, z, attempts, pins, elapsedMs Open77.travel.cancelSettle(ticket) ``` `settleStatus` returns `nil, unknown_settle_ticket` for a ticket that is neither the live session nor the last one to finish. ## Noclip and the map pick ```lua Open77.travel.setNoclip(enabled) Open77.travel.isNoclip() Open77.travel.setNoclipSpeed(metresPerSecond) -- 0.1 .. 500 Open77.travel.getNoclipSpeed() -- current base speed, including wheel changes Open77.travel.setMapPick(enabled) Open77.travel.isMapPick() ``` Noclip moves through the same teleport facility and never takes ownership of the player's movement component. It is camera-relative, uses the player's configured forward/back/left/right keys, and submits one bounded movement request per running game frame. Releasing a movement key does not leave a target that continues to converge. - Space / Ctrl: ascend / descend; mouse wheel: adjust base speed (20% per notch). - Shift: 4x boost; Alt: 0.25x precision; both can be combined. - XInput: left stick moves, right stick looks, triggers ascend/descend, LB boosts and RB slows. - Diagonal movement is normalized. Menus, focused WebUIs, the developer console and loss of window focus suspend movement input; the held position is retained. - Native movement/combat restrictions belong to noclip and are released on exit. It does not heal the player, pause world time or clear restrictions belonging to other resources. - Activation requires a living, attached player outside vehicles and workspot animations. `setNoclip` returns `false, reason` if refused. Death, body replacement or entering a vehicle stops flight. Stopping the owning resource releases flight on the next running frame. - Only the resource which enabled noclip can disable it. All travel calls still require `player.travel`; a gamemode must also enforce server-side ACL before delegating admin use. The admin package retains `/admin.self.noclip`, `/admin.self.fly` and `/admin.self.speed`. Its compact HUD shows current speed and controls without capturing input. Native wheel changes are reflected in both admin interfaces. The diagnostic `noclip.state` is read-only and reports the controller, owning resource, paused input and safety guards. `setMapPick` only lets the world map publish a double-clicked point; it moves nobody, and it does not bypass the ACL-checked server command that consumes the point. ## Loading-screen integration A long-distance teleport may open Cyberpunk's native loading screen. Use the [loading-screen lifecycle](loading-screens.md) to display your own cover or hide a HUD. Loading completion is presentation information; keep the travel API's settling checks for destination readiness. --- Source: https://open2077.net/docs/npcs # Server-owned NPCs Create server-owned NPCs and control their identity, routing bucket, health, tasks and simulation authority. Clients project nearby NPCs into the world but cannot create or mutate their canonical state. The reference implementation is [`resources/system/open77_npcs`](https://github.com/Open2077/open77-resources/blob/main/system/open77_npcs/README.md). Spawn directly from `Character.*` record IDs, without registering a virtual template. Browse the [NPC record catalogue](npc-catalogue.md) for IDs, appearances and compatibility warnings. See [AI, combat and voice control](npc-behavior.md) to make a hostile NPC passive, disable its perception or silence combat/search voice lines. ## Manifest permissions Server mutation requires `world.npcs`. Client inspection requires `npcs.read`. ```lua permissions { "world.npcs", "npcs.read" } ``` ## Architecture - NPC IDs are opaque, generation-aware 64-bit server IDs. - The server streams NPCs only to players in the same routing bucket and inside the configured radius. Hysteresis prevents rapid stream-in/stream-out near the boundary. - One ready client receives a short simulation lease and executes native REDengine commands. The lease carries an epoch; reports from an old owner or epoch are rejected. - Other clients interpolate authoritative motion and never run a competing task. - Late joiners receive the complete NPC state and active tasks. - Movement, look, action and full-body channels execute independently. Starting a look-at does not cancel movement, and starting movement does not stop an active full-body animation. - A resource may inspect and mutate only NPCs it owns. - Stopping a resource removes its non-persistent NPCs. Persistent NPCs must be removed explicitly or restored/managed by server code after a resource restart. ## Character records and legacy aliases Spawn directly with `Open77.npcs.create({ record = "Character.Judy", position = ... })`. No catalogue registration is required. Vanilla, DLC and custom `Character.*` records use the same authoritative lifecycle and replication path. Their record and required assets must exist on every observing client; the headless server cannot inspect a player's TweakDB. Use exact, case-sensitive textual IDs (not hashes or `.ent` paths): `Character.` followed by ASCII letters, digits, `_`, `-` or `.`, at most 255 bytes in total. `record` takes precedence over `template`; an invalid explicit record is rejected, never replaced with a default NPC. For compatibility, `template = "Character.Judy"` and the first argument of the low-level `CreateNpc` (with its existing positional arguments) also accept records. Existing aliases and the old default when **both fields are absent** remain available. `Open77.npcs.templates()` lists these optional legacy aliases, not all spawnable characters: ```lua for _, template in ipairs(Open77.npcs.templates()) do print(template.name, template.record) end ``` | Alias | Faction | Intended use | |---|---|---| | `civilian_female_relaxed_01` | Aldecaldos | Relaxed civilian projection and scripted locomotion. **Not shootable** — see the warning below. | | `hostile_female_ranged_lab` | Maelstrom | Ranged gang combatant. | | `gang_valentinos_ranged_01` | Valentinos | Ranged gang combatant. | | `gang_tygerclaws_ranged_01` | Tyger Claws | Ranged gang combatant. | An alias contains its predefined record, observer record and capability list. An advertised capability describes the underlying rig/template; it does not make an unstable task public. `civilian_female_relaxed_01` resolves to `Character.Panam`, whose `Invulnerable` tag prevents normal combat targeting and damage. Do not arm this template: its weapon graph can crash the client. Use it only as a passive background character; use combat-compatible records for damageable NPCs. **Native combat relations are explicit.** A native gang record runs engine combat AI on every client streaming it. For `aiMode = Open77.npcs.ai.native`, the client makes native NPCs hostile to the local player and, by default, to other native NPCs in their bucket. Choosing the same gang template does not make a team. Idle NPCs acquire the nearest hostile NPC within the existing combat radius; default player acquisition uses engine perception. Resources may opt into a shared team and nearby player acquisition through loadout metadata: ```lua loadout = { combat = { group = "my_resource_guards", acquirePlayers = true }, } ``` Equal nonempty `combat.group` values make native NPC pairs friendly and exclude them from each other's automatic target selection. Groups are case-sensitive, at most 64 ASCII letters, digits, `_`, `-` or `.`; prefix them with the resource name to avoid collisions in a shared bucket. Missing or invalid groups preserve free-for-all behavior. This controls engine relations, not a server damage arbiter: a gamemode should enforce its friendly-fire rules in damage handling too. `acquirePlayers` defaults to `false`. When explicitly `true`, an idle NPC can acquire a living local player in the same bucket within 30 metres, provided a chest-height Static/Dynamic world raycast is clear and no closer hostile NPC was selected. The engine still decides movement and firing. This is a perception aid, not an AI task or a damage grant; gamemode shields and damage validation remain authoritative. Metadata without a top-level `weapon` never enters the player-proxy equipment path. Gang records retain their native inventory. A nested `combat.weapon` is ignored. Deploy a client containing this policy before enabling cooperative camps: older clients ignore the metadata and retain the default hostility sweep. ### Complete 2.31 research catalogue The research catalogue helps find records and appearances for Cyberpunk 2077 2.31 + Phantom Liberty. It does not gate the runtime API: - [6,668 `Character.*` records](../docs/generated/npc-records-2.31.csv), including template, gameplay metadata, crowd appearances and structural risk classification; - [1,524 unique `.ent` templates](../docs/generated/npc-templates-2.31.md), all verified present and readable in the installed base-game/EP1 archives; - [all `.ent` appearance bindings](../docs/generated/npc-entity-appearances-2.31.csv); - [all definitions from the referenced `.app` resources](../docs/generated/npc-appearance-resources-2.31.csv); - [detailed machine-readable `.ent`/`.app` graph](../docs/generated/npc-entity-appearances-2.31.json). The `category` and `risk` columns are research filters, not a runtime allowlist. In particular, `candidate` means only that no obvious quest/player/vendor/special-rig blocker was found in the extracted fields. Access to a record does not guarantee that its quest logic, special rig, animations, appearance or equipment supports every NPC task. Test the records you ship; prefer native inventory over forcing player-proxy equipment onto an unrelated character graph. See the [methodology, source hashes, limitations and validation backlog](../docs/research/npc-templates-and-appearances-catalog.md). ## Create and inspect ```lua local npcId, reason = Open77.npcs.create({ record = "Character.Judy", position = { x = -1378.0, y = 1262.0, z = 123.0 }, yaw = 90.0, bucket = 0, appearance = nil, loadout = {}, aiMode = Open77.npcs.ai.tasks, damagePolicy = Open77.npcs.damage.immortal, health = 100, maxHealth = 100, streamingRadius = 180, streamingHysteresis = 40, despawnWhenUnobserved = false, persistent = false, }) if not npcId then error(reason) end local npc = Open77.npcs.get(npcId) local owned = Open77.npcs.all() -- this resource only local inBucket = Open77.npcs.all(12) -- optional bucket filter ``` `get` returns the template, appearance, loadout JSON, position, bucket, streaming values, health, flags, AI/damage modes, revisions, active task and current authority lease. Limits are enforced server-side: 4096 NPCs globally, 512 per resource and 64 tasks per NPC. ### Server API reference All functions in this table require `world.npcs`. IDs are opaque 64-bit Lua integers. Mutation functions return `true` only when the NPC exists and belongs to the calling resource. Invalid task parameters, IDs or enum values raise a Lua error; callers should treat these as resource bugs rather than normal gameplay failures. `create` is the exception: an invalid record ID, unknown alias or out-of-range field is a **normal rejection**, not an error. It returns `nil, reason` — for example `nil, "npc_record_invalid"` or `nil, "npc_template_not_found"` — so always check both return values. A returned ID confirms canonical server creation, **not** successful engine spawning. Client-side record lookup and asynchronous spawning can still fail; use `onNpcReady` and `onNpcSpawnFailed`. | Function | Parameters | Return | |---|---|---| | `Open77.npcs.create` | `definition` | `npcId`, or `nil, reason` when the subsystem/permission is unavailable | | `Open77.npcs.get` | `npcId` | owned NPC snapshot or `nil` | | `Open77.npcs.all` | optional `bucket` | array of owned NPC snapshots | | `Open77.npcs.templates` | none | array of optional legacy alias snapshots (not an exhaustive catalogue) | | `Open77.npcs.update` | `npcId, fields` | boolean | | `Open77.npcs.setTransform` | `npcId, transform` | boolean | | `Open77.npcs.setBucket` | `npcId, bucket` | boolean | | `Open77.npcs.setAppearance` | `npcId, appearance` | boolean | | `Open77.npcs.setLoadout` | `npcId, loadout` | boolean | | `Open77.npcs.setEquipment` | `npcId, { slot = "Items.*", ... }` | `true`, or `nil, reason` | | `Open77.npcs.getEquipment` | `npcId` | slot-to-record table, or `nil` | | `Open77.npcs.speak` | `npcId, voice, optional options` | `true`, or `nil, reason` | | `Open77.npcs.voices` | none | array of `{ name, context, source }` | | `Open77.npcs.setHealth` | `npcId, health, optional maxHealth` | boolean | | `Open77.npcs.setDamagePolicy` | `npcId, policy` | boolean | | `Open77.npcs.setAiMode` | `npcId, mode` | boolean | | `Open77.npcs.setRagdoll` | `npcId, enabled` | boolean | | `Open77.npcs.setAttitude` | `npcId, attitude, optional options` | boolean, or `false, reason` | | `Open77.npcs.getAttitude` | `npcId` | attitude table or `nil` | | `Open77.npcs.setGroup` | `npcId, group` | boolean, or `false, reason` | | `Open77.npcs.getGroup` | `npcId` | group string or `nil` | | `Open77.npcs.setRelationship` | `groupA, groupB, attitude` | boolean, or `false, reason` | | `Open77.npcs.getRelationship` | `groupA, groupB` | attitude string or `nil` | | `Open77.npcs.target` | `npcId` | `{ kind, id }` or `nil` | | `Open77.npcs.applyDamage` | `npcId, amount, optional source, optional cause` | boolean | | `Open77.npcs.kill` | `npcId, optional reason` | boolean | | `Open77.npcs.revive` | `npcId, optional health` | boolean | | `Open77.npcs.remove` | `npcId` | boolean | | `Open77.npcs.presence` | `npcId` | `{ id, resource, owned, bucket, x, y, z, alive, authorityPlayerId, epoch, leaseMs? }`, or `nil, reason` | `presence` is the single, constant-time read a rule needs when a client acts **for** an NPC: who simulates it (`authorityPlayerId`, `0` when nobody; `epoch`; `leaseMs` only while a lease is live), whether it is `alive` (alive flag and health above zero), and its canonical place and bucket. `owned` says whether the caller created it and `resource` names who did. Unlike `get`, it answers for another resource's NPC too when the caller also declares `npcs.foreign` (otherwise `nil, "permission_denied:npcs.foreign"`), exactly like `all({ includeForeign = true })`. Other reasons: `invalid_npc_id`, `npc_not_found`, `npcs_unavailable`. The [door service](doors.md#npc-passage) uses it to let the simulating client open a door for its NPC. ```lua -- open77.lua: permissions { "world.npcs", "npcs.foreign" } -- Let a client speak for an NPC only while it simulates it, near the target. local function speaksFor(player, npcId, target) local p = Open77.npcs.presence(npcId) if not p or not p.alive or p.authorityPlayerId ~= player or not p.leaseMs then return false end local dx, dy, dz = p.x - target.x, p.y - target.y, p.z - target.z return dx * dx + dy * dy + dz * dz <= 36 end ``` An NPC snapshot contains: | Field | Type | Meaning | |---|---|---| | `id`, `revision`, `taskRevision` | integer | Canonical identity and monotonic revisions. | | `resource` | string | Owning resource. | | `template`, `record`, `observerRecord` | string | Legacy alias, or `"record"` for direct spawning. Both record fields contain the exact Character ID for direct spawns. | | `appearance`, `loadout` | string | Appearance name and canonical loadout JSON. | | `x`, `y`, `z`, `yaw` | number | Canonical transform. | | `bucket` | integer | Routing bucket. | | `streamingRadius`, `streamingHysteresis` | number | Interest thresholds in metres. | | `health`, `maxHealth` | number | Canonical health. | | `flags`, `aiMode`, `damagePolicy` | integer | Values from the constant tables below. | | `currentTaskId` | integer | Preferred active task for compact replication, or `0`. | | `authorityPlayerId`, `authorityEpoch` | integer | Current simulation lease owner and epoch, or `0`. | A template snapshot contains `name`, `record`, `observerRecord`, `defaultAppearance` and a `capabilities` array. ## State mutation ```lua Open77.npcs.setTransform(npcId, { position = { x = 1, y = 2, z = 3 }, yaw = 180 }) Open77.npcs.setBucket(npcId, 7) Open77.npcs.setAppearance(npcId, "appearance_name") Open77.npcs.setLoadout(npcId, { weapon = "Items.Preset_Lexington_Default" }) Open77.npcs.setHealth(npcId, 80, 100) Open77.npcs.setDamagePolicy(npcId, Open77.npcs.damage.mortal) Open77.npcs.setAiMode(npcId, Open77.npcs.ai.tasks) Open77.npcs.setRagdoll(npcId, true) Open77.npcs.remove(npcId) ``` `Open77.npcs.update(id, fields)` can atomically change appearance, loadout, AI mode, damage policy, health, maximum health and ragdoll, and takes an `equipment` table (below). The named setters above are convenience wrappers. Moving an NPC between buckets or teleporting it revokes the current simulation lease before the new state is broadcast. ## Equipment: dressing an NPC `Open77.npcs.setEquipment(id, set)` dresses an NPC in `Items.*` clothing records, on every client that streams it, through the same puppet equipment path the loadout's `weapon` key already uses. It is the server-side counterpart of FiveM's `SetPedComponentVariation` on a ped — a uniform for a job NPC, a costume for an event. ```lua -- A shopkeeper in a work jacket and jeans. The table is the WHOLE desired set. local npc = Open77.npcs.create({ record = "Character.Judy", position = counter }) local ok, reason = Open77.npcs.setEquipment(npc, { OuterChest = "Items.Jacket_01_basic_01", Legs = "Items.Pants_01_basic_01", Feet = "Items.Shoes_01_basic_01", }) if not ok then print("could not dress the clerk: " .. reason) end -- Read it back; the same table also rides `json.decode(Open77.npcs.get(npc).loadout).equipment`. local worn = Open77.npcs.getEquipment(npc) --> { OuterChest = ..., Legs = ..., Feet = ... } -- Swap one piece: send the set again with that slot changed. A slot you leave -- out is a slot you want empty. Open77.npcs.setEquipment(npc, { OuterChest = "Items.Jacket_02_basic_01", Legs = "Items.Pants_01_basic_01" }) -- Beside other fields, in one update. Open77.npcs.update(npc, { equipment = { Head = "Items.Hat_01_basic_01" }, health = 100 }) -- Undress what you dressed. Open77.npcs.setEquipment(npc, {}) ``` Slots are the client's clothing vocabulary: `Head`, `Face`, `InnerChest`, `OuterChest`, `Legs`, `Feet`, `Outfit`, `UnderwearTop`, `UnderwearBottom`. The held weapon is **not** a clothing slot; it stays on `setLoadout(id, { weapon = ... })`. Values are `Items.*` record names (`Items.` followed by an identifier, at most 200 characters); the catalogue of clothing records is `docs/generated/clothing-2.31.csv`. What the server promises, and refuses by name: | Reason | Meaning | |---|---| | `npc_equipment_invalid` | The set is not a table (or, inside `update`, `equipment` is not one). | | `npc_equipment_slot_invalid` | A key is not one of the nine clothing slots. | | `npc_equipment_record_invalid` | A value is not an `Items.*` identifier of at most 200 characters. | | `npc_not_found`, `npc_not_owned` | The id is unknown, or belongs to another resource. | A refused set changes nothing. An accepted set is published as one revision, rides the NPC's loadout envelope to every viewer and to late joiners, and survives a later `setLoadout` that does not mention `equipment` — a resource re-sending a weapon does not strip the uniform. A `setLoadout` that does state `equipment` (an empty table included) replaces it. **What the client does with it.** Every viewer dresses its own puppet instance, one changed slot at a time (`GiveItem` + `AddItemToSlot`, the transaction-system path a vanilla puppet equips its own loadout with), and only ever empties a slot Open77 itself filled: a character's own look lives in its appearance, not in its attachment slots, so nothing native is stripped. The client never runs `InitializeSlots` for clothing — a `Character.*` record declares its attachment slots in TweakDB (`Puppet_Base` declares thirty-three, the nine clothing slots included) and the engine builds the container from that list at spawn; re-initialising it would wipe the weapon the AI holstered. A slot the record does not declare (a mech, an exo, a player puppet record) is refused on the client with a log line `[npc] equipment refused ... reason=slot_unavailable`; a record whose list cannot be read gets nothing (`slots_unknown`). Success is the log line `[npc] equipment applied npc= slot= record=` per slot. **Honest limits.** The server has no TweakDB: existence of the record, and whether the garment mesh binds to that character's rig, are decided on each client against the live game. Clothing records are authored for the player bodies; on a vanilla civilian body some garments will fit and some will clip or float, and only a look at the screen tells which — test the records you ship on the records you ship. There is no NPC-side equipment read on the client Lua surface; `Open77.npcs.getEquipment` (server) and the client log are the readbacks. ### Constants ```lua Open77.npcs.flags.alive Open77.npcs.flags.ragdoll Open77.npcs.flags.despawnWhenUnobserved Open77.npcs.flags.persistent Open77.npcs.ai.tasks Open77.npcs.ai.frozen Open77.npcs.ai.native Open77.npcs.damage.mortal Open77.npcs.damage.immortal Open77.npcs.damage.invulnerable ``` `tasks` is the stable default. `frozen` immediately revokes the simulation lease and suspends every task channel; returning to `tasks` resumes them with fresh timeout/duration accounting. A dead NPC cannot receive a lease, motion report or new task until it is revived. `native` is reserved for templates whose autonomous REDengine behaviour has been explicitly validated. ## Tasks For native AI pause, passive NPCs, sensory acquisition and per-NPC voice suppression, see [NPC AI, combat and voice control](npc-behavior.md). These options are set with `behavior = {...}` at creation or `Open77.npcs.setBehavior(id, {...})` later; snapshots expose the current `behavior` table. They survive loadout changes and stream-out/stream-in. Tasks are server queues partitioned into movement, look, action and full-body channels. Priority is evaluated within a channel. One task may execute in each channel at the same time. A timeout of `0` means no timeout. Task/channel combinations are validated server-side; for example `moveTo` is movement-only, `lookAt` is look-only and `playAnimation` is full-body-only. `wait` may be assigned to any channel and blocks only that channel. | Helper | Channel | Behaviour | |---|---:|---| | `moveTo` | movement | Native navigation to one position. | | `follow` | movement | Re-paths toward a player, NPC or fixed position. | | `patrol` | movement | Sequences arbitrary positions, waits and optional loops. | | `wander` | movement | Deterministic roaming around a centre. | | `face` | look | Rotates the body toward a point. | | `lookAt` | look | Continuously aims the look-at target at a player, NPC or point. | | `wait` | action by default | Server-timed delay. | | `hold` | movement | Holds the current position. | | `playAnimation` | full body | Plays a reviewed named workspot animation. | | `attack` | action | Engages a player or an owned NPC. Composes with a movement task. | | `guard` | movement | Holds an area, and fights from it without being pulled home. | | `flee` | movement | Breaks contact with a player, NPC or fixed point. | ### Move, follow and patrol ```lua local move = Open77.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, { speed = "walk", acceptanceRadius = 1.0, timeoutMs = 30000, }) local follow = Open77.npcs.tasks.follow(npcId, { type = "player", id = playerId }, { speed = "run", distance = 2.0, onTargetLost = "wait", }) local followNpc = Open77.npcs.tasks.follow(npcId, { type = "npc", id = otherNpcId }) local patrol = Open77.npcs.tasks.patrol(npcId, { { x = 10, y = 20, z = 30, waitMs = 500 }, { x = 16, y = 20, z = 30, waitMs = 1000 }, }, { speed = "walk", loop = true, backAndForth = false }) local wander = Open77.npcs.tasks.wander(npcId, { x = 10, y = 20, z = 30, radius = 15, speed = "walk", seed = 42, }) ``` `walk`, `run` and `sprint` are supported movement speeds. Patrol accepts 1 to 64 points. The current `onTargetLost` policy waits for the target to become available again. A walk never arrives by teleport. Before a `moveTo` starts, the client simulating the NPC asks the AI navigation for a path. A destination it cannot reach (a locked door in the way, a wall, a point off the walkable mesh) is not handed to the engine as is: the NPC walks to the closest reachable point, asks a managed door in front of it to open ([NPC passage](doors.md#npc-passage)), and re-checks once a second. If the way opens it walks on; if not, after about five seconds the task ends with `failure` and reason `path_blocked`. Standing within `acceptanceRadius` + 0.5 m of an unreachable point counts as arrived: `success`, reason `nearest_reachable`. | Task end reason | Meaning | |---|---| | `path_blocked` | The destination stayed unreachable (the NPC stopped where the path ends), the engine tried to place the body instead of walking it three times, or a `moveTo` made no progress for five seconds three times. | | `nearest_reachable` | Success next to a destination that cannot itself be stood on. | | `report_jump` | The server refused the position the simulator reported for the task's end (the NPC could not have walked there); the task ends where the server last saw the NPC. | ```lua local move = Open77.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, { speed = "walk" }) AddEventHandler("onNpcTaskState", function(id, taskId, status, reason) if tonumber(taskId) ~= move then return end if status == "failure" and reason == "path_blocked" then print(("NPC %s cannot reach its destination"):format(id)) elseif status == "success" and reason == "nearest_reachable" then print(("NPC %s stopped next to an unreachable point"):format(id)) end end) ``` A native command that still moves the body farther in one frame than its gait allows (the engine's own answer to "no path" is to place the body on the destination) is cancelled and undone on the simulating client before anything about it is published. The same guard covers `patrol`, `wander`, `follow`, `flee`, `guard` and the walk of `enterVehicle`. ### Look, hold and animation ```lua Open77.npcs.tasks.face(npcId, { x = 1, y = 2, z = 3 }, { tolerance = 3, speed = 180, timeoutMs = 5000, }) Open77.npcs.tasks.lookAt(npcId, { type = "player", id = playerId }) Open77.npcs.tasks.wait(npcId, 1500) Open77.npcs.tasks.hold(npcId, { durationMs = 5000 }) Open77.npcs.tasks.playAnimation(npcId, "emote_smoke", { loop = false }) ``` Named full-body animations remain active until they are cancelled, preempted, timed out or the NPC streams out. REDengine does not expose a reliable completion signal for every workspot clip, so use `timeoutMs` or explicit cancellation when the animation must end deterministically. ### Scenarios: workspots A named animation is one clip. A **workspot** is the engine's authored scenario system -- a body mounted into a device that plays a whole behaviour with its own props and its own clip set. It is what the vanilla world uses for a ped smoking on a corner or sitting on a kerb, and it is what `tasks.workspot` drives. ```lua for _, profile in ipairs(Open77.npcs.tasks.workspots()) do print(profile.id, profile.label, profile.category) end Open77.npcs.tasks.workspot(npcId, "smoke", { durationMs = 8000 }) Open77.npcs.tasks.workspot(npcId, "sit", { loop = true }) ``` The catalogue is the twelve shipped RP profiles -- `smoke`, `cigar`, `drink`, `phone`, `dance`, `handsup`, `meditate`, `sit`, `clap`, `cry`, `think`, `stretch` -- and it is the **same list** `Open77.animations.list()` offers for players. One catalogue, two callers. Each entry names the vanilla `.workspot` resource it plays, so a profile is traceable back to the authored asset rather than to a clip name someone typed. A reference that is not a catalogue id is played as a **raw clip** through the generic device, the same path `playAnimation` uses. That is the escape hatch and it is not the supported form: a raw clip's animset may not be loaded by the generic device, in which case the engine simply does not play it. | Option | | |---|---| | `durationMs` | ends the scenario by **succeeding**. A scenario that ran its course is not a failure, which is why this is not `timeoutMs` | | `loop` | `true` has no end; the task runs until cancelled or preempted | | `clip` | a specific clip from the profile's set, instead of its default | | Reason | Meaning | |---|---| | `npc_task_invalid_workspot` | the reference was not a string, or a profile was named together with a clip name | | `npc_task_invalid_duration` | `durationMs` was negative or above one hour | ### Getting into a vehicle ```lua Open77.npcs.tasks.enterVehicle(npcId, vehicleId, "driver") Open77.npcs.tasks.enterVehicle(npcId, vehicleId, 0, { warp = true }) Open77.npcs.tasks.exitVehicle(npcId) ``` Movement channel -- walking to a car is locomotion, so it replaces a standing `moveTo` rather than composing with it. Seats use the `Open77.vehicles.seats` numbering (`-1` driver, `0` front passenger, `1` rear left, `2` rear right) and also accept `driver`, `frontPassenger`, `rearLeft`, `rearRight` by name. The NPC approaches to within `approachDistance` (default 3 m), then mounts. `warp = true` skips the approach. The task reports `executing` until the native mounting relation confirms the NPC is seated. `exitVehicle` is idempotent: a body already out of the car has done what was asked and the task succeeds with reason `not_in_vehicle`. Passing `vehicleId` narrows it to one car, and a body sitting in a different one then fails with `vehicle_mismatch` instead of being pulled out of it. | Reason | Meaning | |---|---| | `npc_task_invalid_target` | no `vehicleId`, or zero | | `npc_task_invalid_seat` | outside `-1..2`, or an unknown seat name | | `vehicle_not_streamed` | no client has that car streamed in, so there is nothing to walk to | | `vehicle_mount_refused` | the engine refused the seat | ### Driving An NPC that is the attached driver of a vehicle can be given driving orders **by ped id**: ```lua Open77.vehicles.ai.attachDriver(vehicleId, { npcId = npcId }) Open77.npcs.tasks.driveTo(npcId, { x = 100, y = 200, z = 10 }, { speed = 20, style = "aggressive" }) Open77.npcs.tasks.driveWander(npcId) Open77.npcs.tasks.chase(npcId, { playerId = source }) Open77.npcs.tasks.setDriverAbility(npcId, 0.9) Open77.npcs.tasks.stopDriving(npcId) ``` These are **not** a second driving system. They resolve the vehicle the NPC drives and forward to [`Open77.vehicles.ai`](server-api.md), which owns the vanilla `AIVehicleDriveToPointAutonomousCommand` pipeline; two stacks that could disagree about where a car is going would be worse than one. Every one of them returns `nil, "npc_not_driving"` until the NPC is actually attached as a driver, and returns the driving state when it is. Two things do not map from GTA and are named rather than faked: * **`driveWander` is `joinTraffic`**, the engine's own lane-following mode. Cyberpunk traffic AI drives authored lanes; there is no free-roam wander to expose. * **`setDriverAbility` is a mapping, not a skill parameter.** 2.31 has no driver-ability field, so a 0..1 ability is mapped onto the two levers that do exist: below `0.34` is `cautious`, above `0.67` is `aggressive`, otherwise `normal`; the speed cap becomes `8 + ability * 24` m/s. It returns the resulting driving state so a caller reads what was applied instead of assuming. `attachDriver` refuses an NPC that holds any live task (`driver_npc_has_tasks`), so finish or cancel an `enterVehicle` before attaching. ### Generic queue and cancellation ```lua local taskId = Open77.npcs.tasks.enqueue(npcId, "moveTo", { x = 1, y = 2, z = 3, speed = "walk", }, { channel = Open77.npcs.channels.movement, priority = 10, timeoutMs = 30000 }) local task = Open77.npcs.tasks.get(npcId, taskId) local tasks = Open77.npcs.tasks.all(npcId) Open77.npcs.tasks.cancel(npcId, taskId, "script_cancel") Open77.npcs.tasks.clear(npcId, Open77.npcs.channels.movement, "new_route") ``` Only the task names listed above are accepted. Unsupported types and malformed targets, paths, durations or animation names are rejected before replication. The five driving verbs are the exception to the shape above: they are commands, not queued tasks, because the job they drive lives in the vehicle AI service and already has its own lifecycle. They return a driving state rather than a task id. ### Task API reference | Function | Parameters | Return | |---|---|---| | `Open77.npcs.tasks.enqueue` | `npcId, type, parameters, optional options` | task ID | | `Open77.npcs.tasks.moveTo` | `npcId, position, optional options` | task ID | | `Open77.npcs.tasks.follow` | `npcId, target, optional options` | task ID | | `Open77.npcs.tasks.patrol` | `npcId, points, optional options` | task ID | | `Open77.npcs.tasks.wander` | `npcId, optional options` | task ID | | `Open77.npcs.tasks.face` | `npcId, target, optional options` | task ID | | `Open77.npcs.tasks.lookAt` | `npcId, target, optional options` | task ID | | `Open77.npcs.tasks.wait` | `npcId, durationMs, optional options` | task ID | | `Open77.npcs.tasks.hold` | `npcId, optional options` | task ID | | `Open77.npcs.tasks.playAnimation` | `npcId, animation, optional options` | task ID | | `Open77.npcs.tasks.workspot` | `npcId, profile or clip, optional options` | task ID | | `Open77.npcs.tasks.workspots` | `optional query` | array of catalogue profiles | | `Open77.npcs.tasks.enterVehicle` | `npcId, vehicleId, optional seat, optional options` | task ID | | `Open77.npcs.tasks.exitVehicle` | `npcId, optional options` | task ID | | `Open77.npcs.tasks.driving` | `npcId` | driving state or `nil, reason` | | `Open77.npcs.tasks.driveTo` | `npcId, position, optional options` | driving state or `nil, reason` | | `Open77.npcs.tasks.driveWander` | `npcId, optional options` | driving state or `nil, reason` | | `Open77.npcs.tasks.chase` | `npcId, target, optional options` | driving state or `nil, reason` | | `Open77.npcs.tasks.stopDriving` | `npcId` | driving state or `nil, reason` | | `Open77.npcs.tasks.setDriverAbility` | `npcId, ability` | driving state or `nil, reason` | | `Open77.npcs.tasks.attack` | `npcId, target, optional options` | task ID | | `Open77.npcs.tasks.guard` | `npcId, position, optional radius, optional options` | task ID | | `Open77.npcs.tasks.flee` | `npcId, from, optional options` | task ID | | `Open77.npcs.tasks.get` | `npcId, taskId` | task snapshot or `nil` | | `Open77.npcs.tasks.all` | `npcId` | array of task snapshots | | `Open77.npcs.tasks.cancel` | `npcId, taskId, optional reason` | boolean | | `Open77.npcs.tasks.clear` | `npcId, optional channel, optional reason` | number cancelled | Common options are `priority` (signed integer) and `timeoutMs` (`0` disables the timeout). Generic `enqueue` also accepts `channel`. A task snapshot contains `npcId`, `id`, `resource`, `type`, the JSON string `parameters`, `channel`, `priority`, `timeoutMs`, `status`, `revision` and `reason`. Statuses are `queued`, `suspended`, `executing`, `success`, `failure`, `cancelled` and `interrupted`. ## Attitude and relationship groups `setAttitude` decides who an NPC will fight. It is **per NPC and per target**, and that is not a design preference -- it is the only shape the engine can express for one incarnation. A `gameAttitudeAgent` carries `SetAttitudeTowards(otherAgent, attitude)`, an override on one pair of agents, which is what Open77 writes. The engine's other lever, a global group matrix reached through `gameCAttitudeManager.SetAttitudeRelation`, is process-wide and shared with vanilla content, so Open77 does not touch it; `docs/research/npc-behavior-control.md` records that measurement in full. ```lua -- Hostile to every player and to every other NPC of this resource. Open77.npcs.setAttitude(npcId, "hostile") -- ...except this one player, who is an ally. Open77.npcs.setAttitude(npcId, "friendly", { towards = { playerId = playerId } }) -- ...and this NPC, which it simply ignores. Open77.npcs.setAttitude(npcId, "neutral", { towards = { npcId = otherNpcId } }) -- Take the exception back; the default applies again. Open77.npcs.setAttitude(npcId, nil, { towards = { playerId = playerId } }) ``` Rows resolve in a fixed order, most specific first: 1. the per-entity row (`{ playerId = n }` or `{ npcId = n }`); 2. the per-group row (`{ group = "name" }`); 3. the relationship row declared by `setRelationship` for the two NPCs' groups; 4. the default set by `setAttitude` with no `towards`; 5. otherwise the NPC behaves exactly as it did before any of this existed. Two rules are worth reading twice. An NPC that states **nothing** replicates the same loadout bytes it always did and is resolved by the pre-existing rules, so adding this API changed no running resource. And player **eligibility is a hard cap, not a default**: a dead player, or one in another routing bucket, is never made a target by a directive. A directive can only ever make an NPC friendlier toward an ineligible player, never hostile. Attitude survives a wholesale `setLoadout`, the way `behavior` does, because it is policy rather than equipment. `combat.group` does not: it is an ordinary loadout field with pre-existing meaning. ### Relationship groups Groups are the readable way to run more than two sides. `setGroup` writes the same `combat.group` field a loadout has always been able to carry -- two NPCs sharing a non-empty group are allies -- and `setRelationship` declares what happens between two groups. ```lua Open77.npcs.setGroup(copId, "police") Open77.npcs.setGroup(gangerId, "valentinos") Open77.npcs.setRelationship("police", "valentinos", "hostile") Open77.npcs.setRelationship("police", "medics", "friendly") ``` The matrix is **per resource**, symmetric, and dies with the resource that declared it: one resource's groups never reach another's bodies, exactly as its NPC reads and writes never do. The resulting row is projected onto every owned NPC in either group and replicated with it, so every observer resolves the pair without asking the server -- and an NPC that joins a group later picks up the standing row. When two NPCs state attitudes about each other that disagree, **the more hostile side wins**: a one-sided friendship produces a body that is shot at and does not shoot back. ### Combat tasks The combat tasks are ordinary queue entries on the existing channels, so they compose with the movement and look tasks already there. ```lua -- Engage. Action channel: a standing `moveTo` or `patrol` is NOT cancelled -- the engine -- suspends it for the fight and resumes it afterwards. Open77.npcs.tasks.attack(npcId, { playerId = playerId }) Open77.npcs.tasks.attack(npcId, { npcId = rivalId }, { reacquireMs = 3000 }) -- Hold a doorway. Standing order: it ends on cancel or timeout, never on its own. Open77.npcs.tasks.guard(npcId, { x = 10, y = 20, z = 30 }, 12, { speed = "run" }) -- Break contact. Open77.npcs.tasks.flee(npcId, { playerId = playerId }, { distance = 60 }) ``` `attack` forces the pair hostile and seeds the target into the puppet's own target tracker, then re-seeds on `reacquireMs`. It issues **no** locomotion or shooting command: the engine's combat AI owns the body from there, which is why an attacking NPC can still take cover and manoeuvre. It reports `success` when the target dies, streams out or is removed, and `failure` with `attack_target_unavailable` when the target could not be resolved at all. Cancelling drops the seeded threat but leaves the attitude alone -- that was a deliberate statement, and a task must not overwrite it. `guard` is a leash, not a patrol. Whenever the body is further than `radius` from the anchor **and is not already in a fight** it walks back; while it holds a hostile threat it is left entirely alone. It does not by itself make anything hostile: pair it with `setAttitude` or a group. `flee` re-measures a live target every cycle, so an NPC keeps breaking contact as the thing it flees moves, and reports `success` once `distance` metres separate them. It drops the tracked threat on that body once, which is best-effort and deliberately shallow: the attitude is untouched, so a still-hostile NPC may re-acquire the same target by perception. `setAttitude(npcId, "neutral", { towards = ... })` before the flee is the composable answer. **There is no `cover` task.** Native cover commands require authored `NodeRef` positions that Open77 cannot create or enumerate. NPCs can use native cover during combat. The `guard` task uses a `moveTo` leash rather than a native guard-area command, which has the same `NodeRef` restriction. ## Health, damage and death ```lua Open77.npcs.applyDamage(npcId, 25, "resource:arena", "firearm") Open77.npcs.kill(npcId, "admin") Open77.npcs.revive(npcId, 100) ``` - `mortal` allows health to reach zero. - `immortal` applies damage but clamps health to 1. - `invulnerable` rejects damage. Health and death are canonical server state. Client hit detection should request a bounded server action; it must not directly mutate a projection. ## Events Server resource events: ```lua AddEventHandler("onNpcCreated", function(npcId, resource, template) end) AddEventHandler("onNpcUpdated", function(npcId, revision) end) AddEventHandler("onNpcRemoved", function(npcId, reason, resource) end) AddEventHandler("onNpcTaskState", function(npcId, taskId, status, reason) end) AddEventHandler("onNpcAuthorityChanged", function(npcId, playerId, epoch, reason) end) AddEventHandler("onNpcDamaged", function(npcId, source, amount, health, cause) end) AddEventHandler("onNpcDied", function(npcId, source, cause) end) AddEventHandler("onNpcTargetChanged", function(npcId, kind, targetId, previousKind, previousTargetId) end) AddEventHandler("onNpcInteracted", function(npcId, playerId, interactionId, choiceId, distance) end) ``` The same creation and removal also reach `onEntityCreated(kind, id, resource)` and `onEntityRemoved(kind, id, reason)` with `kind` = `"npc"`, for a resource that declares `world.entities.observe` and wants every registry under one name. The mirror is raised by the same statement as the event above it, so the two can never disagree — see [entity lifecycle events](server-api.md#entity-lifecycle-events). `onNpcTargetChanged` is delivered **only to the resource that owns the NPC**, unlike the six above it, because NPC reads are owner scoped everywhere else in this API and an event telling every resource which player another resource's bodyguard had just drawn on would be a read across that boundary. `kind` is `none`, `player` or `npc`, and `targetId` is `0` when `kind` is `none`. All five arguments are strings, as every server resource event's arguments are. `onNpcInteracted` is a **player's** act on an NPC — a choice used on a `globalNpc` [interaction target](interactions.md#event-payload-and-server-authority) — and is delivered host-wide like `onNpcDamaged`: the owner is not the only resource entitled to know that a player pressed a key on its clerk. The bundled prompt reports the use to the server; the server refuses a report from a player it cannot place, from another routing bucket, or from more than 40 m away, and publishes the event only for an accepted one, with `distance` being **its own** measurement between the player's last fresh snapshot and the NPC's canonical position (metres, two decimals). `interactionId` is the target's materialised id (`:`) and `choiceId` the choice; a refused report produces no event at all, so a forged one can only be silent. Apply your own rule on `distance` — 40 m is the ceiling that keeps a sprinting player's stale snapshot from breaking a real prompt, not the reach of your shop. ```lua -- A vendor: one NPC per stall, opened by the prompt the interactions resource shows on it. local stalls = {} -- npcId (string) -> catalogue CreateThread(function() local npcId = Open77.npcs.create({ record = "Character.Judy", position = { x = -1378.0, y = 1262.0, z = 123.0 } }) stalls[tostring(npcId)] = "weapons" exports.open77_interactions:define({ { id = "stall", kind = "globalNpc", distance = 2.0, label = "Browse", key = "E", event = "market:browse" }, }) end) AddEventHandler("onNpcInteracted", function(npcId, playerId, interactionId, choiceId, distance) local catalogue = stalls[npcId] if catalogue == nil or tonumber(distance) > 3.0 then return end TriggerClientEvent("market:open", tonumber(playerId), catalogue) end) ``` Client resource events: ```lua AddEventHandler("onNpcStreamIn", function(npcId, revision) end) AddEventHandler("onNpcReady", function(npcId, entity) end) AddEventHandler("onNpcSpawnFailed", function(npcId, record, reason) end) AddEventHandler("onNpcChanged", function(npcId, revision) end) AddEventHandler("onNpcTaskChanged", function(npcId, taskId) end) AddEventHandler("onNpcAuthorityChanged", function(npcId, playerId) end) AddEventHandler("onNpcStreamOut", function(npcId, reason) end) ``` **No `RequestModel` / `HasModelLoaded` loop is needed.** Streaming is implicit; `create` returns before the body exists anywhere, `isStreamedIn(id)` says whether this client has it and `onNpcReady` is the transition. `Open77.npcs.whenReady` folds both into one promise, on both runtimes: ```lua -- Client, requires npcs.read. Resolves with { id, entity } the instant isStreamedIn(id) -- answers true (the same instant onNpcReady fires: attached AND behaving), before the call -- returns when it already is; rejects with `timeout` (default 15000 ms, clamped 1..120000). Open77.npcs.whenReady(npcId, 8000):next(function(body) Open77.animations.play(body.entity, "wave") end, function(reason) print("vendor never appeared: " .. reason) end) -- Server, requires world.npcs. Resolves with the Open77.npcs.owner(id) table as soon as at -- least one client reports a ready projection (readyClients > 0) -- the election's own -- `onNpcAuthorityChanged(..., "projectionready")` event, with a 250 ms poll as the safety -- net -- and rejects with `timeout`. local guard = assert(Open77.npcs.create({ template = "civilian_female_relaxed_01", position = post })) Open77.npcs.whenReady(guard, 10000):next(function(owner) Open77.npcs.tasks.patrol(guard, route, { loop = true }) end, function(reason) print(("guard %d never streamed: %s"):format(guard, reason)) end) ``` The one-shot handler is removed on resolve, on timeout and with the resource. `nil, reason` only for a bad id, a bad timeout or a missing permission; an unknown id is waited for and times out. ## Client read-only API `onNpcSpawnFailed(npcId, record, reason)` is a **local diagnostic**, not a server-authoritative failure. Reasons are `npc_record_invalid`, `npc_record_not_found`, `npc_record_not_character`, `npc_spawn_failed` and `npc_spawn_timeout` (30 seconds). A failed projection is not retried every frame: it remains failed until a new incarnation (stream-out/in, recreation, record/appearance change). Transient spawn-service saturation is retried at most once every two seconds. Never trust a client failure notification to delete canonical server state automatically. ```lua local npc = Open77.npcs.get(npcId) local visible = Open77.npcs.all() local ready = Open77.npcs.isStreamedIn(npcId) local entity = Open77.npcs.entity(npcId) -- local Open77 entity handle or nil local taskId = Open77.npcs.currentTask(npcId) -- canonical current task ID or nil ``` Snapshots expose `streamed` and `locallyAuthoritative`. The local entity handle is ephemeral: do not cache it across stream-out, reconnect or resource reload. Client snapshots contain `id`, `revision`, `entity`, `template`, `record`, `appearance`, `flags`, `bucket`, `aiMode`, `damagePolicy`, `health`, `maxHealth`, `currentTaskId`, `taskRevision`, `authorityPlayerId`, `authorityEpoch`, `streamed` and `locallyAuthoritative`. Without `npcs.read`, `all()` returns an empty array, `get()`/`entity()`/`currentTask()` return `nil`, and `isStreamedIn()` returns `false`. ## Current limitations - Server persistence storage is resource-defined; `persistent=true` only changes cleanup policy. - Records outside `Character.*`, raw REDengine handles and client-side canonical mutation are unsupported. A valid Character record may still depend on unavailable DLC/assets or quest logic. - Native attack/shoot/melee/combat tasks are not in the stable API yet. Use server-authoritative scripted damage until animation, targeting and hit validation are proven safe for each rig. - Native authored patrol paths (`NodeRef`) are not exposed; Open77 patrols sequence `moveTo` tasks. - A client authority lease is required for native navigation. With no ready client, tasks suspend and resume when authority becomes available. --- Source: https://open2077.net/docs/npc-behavior # NPC AI, combat and voice control Control individual spawned NPCs from server Lua with `Open77.npcs`. Behavior settings apply to the selected NPC, not its gang, other NPCs using the same record, player characters or global audio. Available in client and server **2.31.13+op77.55**, using protocol **1.24**. Both sides must be updated to apply the behavior policy. Find spawn IDs in the [NPC record catalogue](npc-catalogue.md). ## Spawn a passive, silent NPC ```lua -- Server manifest: permission "world.npcs" local npc, reason = Open77.npcs.create({ record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa", position = { x = 381.0, y = -2400.0, z = 182.0 }, aiMode = Open77.npcs.ai.tasks, behavior = { combatEnabled = false, voiceEnabled = false, }, }) if not npc then print(reason); return end -- Optional: pause both the native AI agent and Open77 task execution. local ok, error = Open77.npcs.setAIEnabled(npc, false) if not ok then print(error) end -- Resume the selected aiMode, without changing the passive/silent options. Open77.npcs.setAIEnabled(npc, true) ``` The record and its assets must exist on each client. A successful `create` returns a canonical server ID, not proof that the engine has spawned the body. See [NPC lifecycle and readiness](npcs.md). ## Independent controls All four options default to `true`. Omitting an option preserves its current value when updating. | Option | Server helper | Effect of `false` | | --- | --- | --- | | `aiEnabled` | `Open77.npcs.setAIEnabled(id, false)` | Disables the native AI agent and its perception; suspends Open77 tasks and revokes its simulation lease. Rendering/collision are not disabled. | | `combatEnabled` | `Open77.npcs.setCombatEnabled(id, false)` | Clears tracked threats, disables threat acquisition/reactions and blocks aggression and outgoing hit processing. The movement agent remains available for scripted tasks. | | `perceptionEnabled` | `Open77.npcs.setPerceptionEnabled(id, false)` | Disables autonomous sensory/stimulus acquisition, including Open77's automatic target seeding. It does not clear a previously assigned combat target; use `combatEnabled=false` to stop combat. | | `voiceEnabled` | `Open77.npcs.setVoiceEnabled(id, false)` | Disables the NPC's native voiceset lines and grunts, including combat/search barks that bypass scripts. Script-triggered voiceovers and native dialogue routed through subtitles are also filtered. Footsteps, weapons and other sound effects are not muted. | Each helper requires a **boolean**, not `0`, `1`, or `"false"`. Use one atomic update for several options: ```lua local ok, reason = Open77.npcs.setBehavior(npc, { aiEnabled = true, combatEnabled = false, perceptionEnabled = false, voiceEnabled = false, }) local behavior = Open77.npcs.getBehavior(npc) -- Equivalent read: Open77.npcs.get(npc).behavior ``` `setBehavior` and the four setters return `true`, or `false` with a validation reason when supplied data is invalid. Unknown/not-owned NPCs and missing permission return `false`. `getBehavior` returns a detached table or `nil` when unavailable. Malformed options return `npc_behavior_invalid`; unknown option names return `npc_behavior_unknown_option`. `Open77.npcs.update(id, { behavior = {...} })` accepts the same partial object. Changing the loadout/appearance, changing bucket, or reviving the NPC does not reset these settings. They are not stored in client KVP and do not constitute disk persistence across server restarts. Two further options govern damage rather than AI: `vehicleContactEnabled` (default `false`, [below](#vehicle-contact-damage)) and `hitPricing` (default `"resource"`, [below](#who-prices-player-hits-hitpricing)). They are set the same way, at creation or later, and read back from `getBehavior`: ```lua local npc, reason = Open77.npcs.create({ record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa", position = { x = 381.0, y = -2400.0, z = 182.0 }, damagePolicy = Open77.npcs.damage.mortal, behavior = { vehicleContactEnabled = true, hitPricing = "platform" }, }) if not npc then print(reason); return end print(Open77.npcs.getBehavior(npc).hitPricing) --> platform ``` ## Vehicle contact damage `behavior.vehicleContactEnabled` defaults to `false`. The NPC's creating server resource may opt in with `Open77.npcs.setBehavior(id, { vehicleContactEnabled = true })`. This replaces native vehicle-hit damage pricing with server-priced contact damage, and it keeps the NPC's damage policy (`invulnerable`, `immortal` or `mortal`). The client simulating the NPC reports the contact; the server checks its current lease, world readiness, revision, bucket and the vehicle's recent trajectory, with a two-second contact cooldown per NPC. Damage uses the same speed curve as car impacts on players (the `combat.vehicleHitDamage*` server options), derived from accepted positions. Nothing scans the world for contacts every frame. An accepted contact adopts an existing native fall or requests a native NPC knockdown. Identical ragdoll trajectories on every screen are not guaranteed. ## Who prices player hits: `hitPricing` An NPC's health and death belong to the server. No client lets the game change a network NPC's health or kill it on its own: a shot plays its hit reaction, and the NPC loses health or dies only when the server's ledger says so, on every client at once. By default the **resource** that created an NPC prices player hits on it, typically from the client event `open77:npcHit` it forwards to its own server script and a weapon table. A resource that prices nothing leaves players unable to kill its NPCs: they react to shots but never lose health. A resource can instead **opt in** to the platform's pricing for a **mortal** NPC (`damagePolicy = Open77.npcs.damage.mortal`). The shooter's client then sends the engine's own price for each hit, and the server applies it with `Open77.npcs.applyDamage` semantics. `onNpcDamaged` and `onNpcDied` receive the source `"player:"` and the cause `"player_hit"`. ```lua -- A guard that players may kill, with no hit pricing of your own. local guard = Open77.npcs.create({ record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa", position = { x = 381.0, y = -2400.0, z = 182.0 }, damagePolicy = Open77.npcs.damage.mortal, behavior = { hitPricing = "platform" }, }) -- or later: Open77.npcs.setBehavior(guard, { hitPricing = "platform" }) ``` **Resources that create mortal NPCs and do not price `open77:npcHit` should opt in**, or players cannot kill those NPCs. The opt-in exists because the amount is still the shooter's own claim: every report must ride shots the server itself admitted (see below), but the server has no weapon damage table, so a modified client could still claim one maximum per admitted shot. Price hits in your resource when that matters. The server checks each report before applying it: - the shooter is alive, has a weapon drawn according to its own recent player snapshot, has a fresh position in the NPC's routing bucket and is within 150 m of it; - the report names at least one shot (a spent round or a melee swing) that the server admitted for that drawn weapon at the weapon class's fire rate, fired within the last 3 s (1.5 s for a swing) from within 150 m (12 m for melee) of the NPC, and not yet used against this NPC. Each shot prices a given NPC once. The server option `combat.handheldDischarges` (default `true`) runs this admission; turning it off turns platform pricing off; - the NPC is alive, mortal and opted in; - one report is worth at most the NPC's maximum health; - a player may apply at most 40 reports and 6 × maximum health per second across all NPCs; - an NPC may receive at most 24 reports and 2 × maximum health per second from all players. Immortal and invulnerable NPCs are never priced this way. | `hitPricing` | Player hits on a mortal NPC | | --- | --- | | `"resource"` (default) | Never priced by the platform; only what the resource applies changes the NPC. `open77:npcHit` still fires on the shooter's client. | | `"platform"` | Priced by the server as above. `open77:npcHit` still fires as well; do not also price it, or each shot counts twice. | The value must be one of these two strings; anything else returns `npc_behavior_invalid`. `Open77.npcs.getBehavior(id).hitPricing` reads it back. The bundled Cordon, Deathmatch and Freeroam PvP bots state `"resource"` explicitly. Damage no player caused is always reported by the client that simulates the NPC: environment, falls, fire, explosions without a player instigator, and the NPC's own grenade. Vehicle collisions are not player hits; opt in to [vehicle contact damage](#vehicle-contact-damage) for those. ### Server configuration Both options live in the `combat` section of `server.jsonc` and are read at startup: ```jsonc "combat": { "handheldDischarges": true, // default: admit handheld shots, required by "platform" pricing "handheldVehicleDamage": false // default: see Armed vehicles & Lua API } ``` `handheldDischarges` (default `true`) makes the server bind each living body and admit fire declarations against the drawn catalogued weapon and a per-class cadence. A receipt damages nothing by itself; platform-priced NPC hits require one. With `false`, no binding is sent and platform-priced NPCs cannot be priced from a player's hit. `handheldVehicleDamage` is described in [Armed vehicles & Lua API](vehicle-weapons.md#handheld-guns-on-cars-another-player-drives). ## Speech: one line, on demand `Open77.npcs.speak(id, voice, options)` is the ON switch beside `setVoiceEnabled`'s OFF: one voice-over line from the NPC's own voiceset, queued on every client that has the body streamed. It is the counterpart of FiveM's `PlayPedAmbientSpeechNative`. What is proven is the plumbing — the `SoundPlayVo` event reaches each viewer's puppet, and a muted NPC refuses — not the sound: the test loop has no audio capture, so whether a given line is *heard* on a given record is something to check by ear before shipping it. ```lua -- A guard greets whoever walks up, and warns them off when they linger. local guard = Open77.npcs.create({ record = "Character.Judy", position = gate }) AddEventHandler("onNpcInteracted", function(npcId, playerId) if npcId ~= guard then return end local ok, reason = Open77.npcs.speak(guard, "greeting") if not ok then print("guard stayed silent: " .. reason) end end) Open77.npcs.speak(guard, "rep_ask_to_leave", { ignoreDistance = true }) ``` `voice` is a **`voContext` name** — the same word vanilla passes to `GameObject.PlayVoiceOver`, resolved by the engine against the puppet's voiceset (`Character.*.voiceTag`) — not a Wwise event; `Open77.effects.sound` remains the way to play a sound bank event on an NPC. `Open77.npcs.voices()` lists the generic barks that can be pointed at in the game's own 2.31 sources, each with the file and line that plays it: | Name | When vanilla plays it | |---|---| | `greeting` | look-at reaction to a friendly passer-by | | `bump` | bumped into by the player | | `fear_beg`, `fear_run`, `fear_foll` | a civilian threatened: begging, fleeing, complying | | `stlh_curious`, `stlh_curious_grunt`, `stlh_investigate`, `stlh_search`, `stlh_patrol_back`, `stlh_call`, `stlh_death` | the stealth ladder: noticing, investigating, searching, giving up, calling a friend, finding one dead | | `start_alerted`, `start_combat`, `combat_ended`, `danger`, `enemy_warning`, `combat_target_hit`, `combat_target_sight_lost`, `crowd_combat` | alert and combat state changes | | `attack_short`, `attack_long`, `enemy_melee_charge`, `battlecry_curse` | attack shouts | | `hit_reaction_light`, `hit_reaction_heavy`, `vo_any_damage_hit` | taking hits | | `grenade`, `grenade_throw`, `coop_reports_kill` | grenades and kill calls | | `rep_ask_to_leave`, `rep_ask_to_holster`, `rep_final_warning`, `rep_call_grd`, `rep_complies` | a guard's escalation ladder | | `hurry_up`, `phone_start`, `taunt_hidden_player`, `pedestrian_hit`, `vehicle_bump` | miscellany | The list is documentation, not an allowlist: any identifier is accepted (letters, digits, underscore, at most 64), because a record may carry lines no vanilla script calls. **A name the voiceset does not have is silent, and nothing can report that** — the engine drops it without a word, so `true` means "queued on every viewer", not "heard". Test the names you ship on the records you ship; a character whose record has no `voiceTag` never speaks. Options: `ignoreFrustum` (default `true`) plays even when the NPC is off-screen; `ignoreDistance` (default `false`) skips the engine's distance cull. Refusals, by name: | Reason | Meaning | |---|---| | `invalid_voice` | Not an identifier, empty, or longer than 64. | | `invalid_npc_id`, `npc_not_found`, `npc_not_owned` | Bad id, unknown id, another resource's NPC. | | `npc_dead` | A dead body has nothing to say. | | `voice_disabled` | `setVoiceEnabled(id, false)` is in force; lift it first. | | `npc_not_streamed` | No client has reported the body ready yet — nobody could hear it. | | `npc_voice_busy` | A line was accepted on this NPC less than 400 ms ago. | Requires `world.npcs` and ownership, like every NPC mutator. The line rides the existing effect one-shot to every client in the NPC's routing bucket; a client that does not have the body streamed resolves no target and drops it, which is the same rule an entity-bound sound follows. On the client the bundled [`open77_effects`](https://github.com/Open2077/open77-resources/tree/main/system/open77_effects) resource queues the engine's own `SoundPlayVo` event on the puppet through `Open77.sfx.playVoice`, which a client resource may also use for a purely local bark (see [Effects](effects.md)). ## Tasks, authority and replication `aiMode` and behavior are different controls. `frozen` suspends Open77's task scheduler; **use `setAIEnabled(id, false)` when the native agent must also stop**. Re-enabling AI does not change `aiMode`: a frozen task scheduler stays frozen until its mode is changed. Tasks resume with renewed timing rather than expiring because of the paused interval. The server validates resource ownership and replicates the full policy in reliable NPC state, including to late joiners, observers and newly streamed incarnations. Engine mutations run on the game thread. An unavailable native/component is logged as `behavior pending`, not reported as a successful application. Applying these options requires the matching updated client and server; the existing 1.24 wire layout is unchanged, but older clients ignore the new policy. Clients with `npcs.read` can inspect `Open77.npcs.get(id).behavior` and `.all()`, but cannot override the server policy. Custom network event handlers must validate ownership themselves; never forward an arbitrary client-supplied NPC ID to these setters. If your gamemode accepts NPC hit reports, re-check current canonical behavior before applying damage: ignore reports when `aiEnabled` or `combatEnabled` is false. A delayed report is not permission to damage a player after a server-side policy change. ## Boundaries - These APIs control Open77-created NPCs, not arbitrary ambient world handles. - Voice suppression is prospective. It does not promise to interrupt every line already playing, or audio emitted outside the game's voice/dialogue pipelines. - Disabling AI is not time dilation, a pose freeze, ragdoll, invulnerability or hiding a character. Use the dedicated animation and damage APIs for those effects. - Perception off does not override explicit script commands. Combat off does not prevent your own Lua code from deliberately calling a damage API or playing a custom attack animation. - Vehicle auto-driving belongs to `Open77.vehicles.ai`; disabling a seated NPC's agent is not a substitute for stopping the vehicle's drive command. --- Source: https://open2077.net/docs/npc-catalogue # NPC record catalogue Browse **6,582 `Character.*` records** from Cyberpunk 2077 **2.31**, including Phantom Liberty. The index describes game data, not guaranteed multiplayer compatibility. The downloadable CSV also includes 86 internal `TEST.*` records, which the spawn API does not accept. Custom `Character.*` records require their assets on every client. ## Use a record Pass the exact record ID to the server, not the entity template path or its hash: ```lua -- Server manifest: permission "world.npcs" local id, reason = Open77.npcs.create({ record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa", position = { x = 381.0, y = -2400.0, z = 182.0 }, behavior = { combatEnabled = false, voiceEnabled = false }, }) if not id then print(reason) end ``` Creating an ID does not confirm that the body spawned. Check readiness and spawn-failure events as explained in [Server-owned NPCs](npcs.md). For passive characters, task control and silent NPCs, read [AI, combat and voice control](npc-behavior.md). ## Understand the warnings The extraction classifies records structurally, not through in-game testing: | Classification | Meaning | | --- | --- | | Candidate | A starting point for testing; not guaranteed to work or be damageable. | | Quest / scene | May require quest setup, special graphs or invulnerability settings. | | Special rig | Animals, robots, photo-mode and other nonstandard rigs need extra care. | | Vendor | May depend on vendor-specific setup. | | Child | Special child records; not ordinary combat NPCs. | | Player | Player-body records; not ordinary NPC spawn candidates. | | Missing template | No entity template was resolved by this extraction. | An appearance is not a record ID. Appearance names and entity paths help investigate assets; they do not certify rig compatibility. Reusing a quest character for armed combat can be unsafe. The `tweakdb_ep1.bin` source describes the extracted database, **not a reliable per-record DLC requirement**: it also contains base-game entries. Ensure the record and assets exist on every observing client. This catalogue excludes third-party mod archives. ## Download the data Use the [compact JSON catalogue](/data/npc-records-2.31.json) for tooling and agents, or the [complete extraction CSV](/data/npc-records-2.31.csv) for all raw fields. The JSON records the game version, source path and CSV SHA-256 so tools can identify the exact dataset. `Open77.npcs.templates()` still returns the small set of **legacy aliases**, not this catalogue. Neither the catalogue nor an alias registration is required to spawn a valid `Character.*` record. --- Source: https://open2077.net/docs/player-models # Player models: NPC impersonation Select an NPC model as a player's visible body while retaining player identity and gameplay state. Find 6,582 `Character.*` IDs in the [morph catalogue](player-model-catalogue.md). NPC impersonation is experimental. Movement, vehicle poses and life transitions depend on the model's rig; inclusion in the catalogue does not guarantee compatibility with every player action. A server resource can select a `Character.*` record as a player's visible body. The player retains their network identity, controls, health, equipment inventory and saved appearance. The NPC is a presentation body, not an independently controlled AI actor and not a second player. Resetting the model restores the current player appearance. Models use their own native rig/graph and appearance. Player wardrobe meshes and player-specific Mirror animation clips are not applied to an arbitrary NPC skeleton. The record is resolved from the client's installed game/mod data, without a virtual catalogue and without changing shared TweakDB records. Internally, the client builds a process-cached presentation record from the ordinary proxy base and copies only the requested NPC's visual identity. The requested record remains the API identity. This prevents importing a boss's stat packages or quest loadout along with its skin; it does not guarantee that every authored template or special skeleton supports every player action. ### Vehicle compatibility The original player keeps the actual seat and driving authority; the visible NPC follows a presentation-only seat pose and never takes ownership of the car. While a model is active, the native vehicle camera stays in third person where supported. If the passenger camera remains in first person, a vehicle-following third-person fallback provides mouse orbit and three distances through the player's existing vehicle-camera binding. Reset restores the prior view; protected scenes and resource-owned scripted cameras retain priority. **Special rigs may not support vehicle seats.** Adam Smasher uses `man_massive`; seats without a matching animation binding can display a T-pose or clip the body through the vehicle. The API does not replace incompatible models automatically. ## Permissions and ownership ```lua permissions { "players.model.control", "players.model.read" } ``` `players.model.control` permits server setters. `players.model.read` permits queries, and client record validation. A resource **generation** exclusively owns each active override. A second resource cannot replace or reset it; it receives `model_owned_by_other_resource`. Stopping/restarting the owner releases its overrides. Allow client requests only after checking your own gameplay/ACL rules server-side. There is deliberately no client model setter and no unrestricted native access. ## Server API | Function | Result | | --- | --- | | `SetPlayerModel(playerId, record, options?)` | `true, revision` or `false, reason` | | `ResetPlayerModel(playerId)` | `true` or `false, reason` | | `GetPlayerModel(playerId)` | state table, `nil` for original model, or `nil, reason` | | `IsPlayerModelReady(playerId)` | boolean or `nil, reason` | Aliases: `Open77.players.setModel`, `.resetModel`, `.getModel`, `.isModelReady`. Player IDs must be positive integers; numeric strings are not accepted. Options (unknown fields and wrong types are rejected): | Field | Default | Meaning | | --- | --- | --- | | `appearance` | `""` | Native appearance CName; empty keeps the template default. Maximum 128 ASCII characters. | | `durationMs` | `0` | Integer 0–86,400,000; zero lasts until reset or owner/disconnect cleanup. | | `resetOnDeath` | `false` | If true, remove the override on death; otherwise retain it for respawn. | Record strings must start with `Character.`, have 11–255 ASCII characters and contain only letters, digits, `_`, `.` and `-`. Appearance strings allow the same characters plus spaces. Asset paths, escape sequences and arbitrary native fields are not supported. Record availability is checked on the client, not inferred by the server from the name. A missing model therefore fails asynchronously. ```lua local ok, revisionOrError = SetPlayerModel(playerId, "Character.Rogue", { durationMs = 60000, resetOnDeath = false, }) if not ok then print("Model refused: " .. revisionOrError) end -- A true setter result means accepted, NOT already visible. ``` The server state table contains `player`, `record`, `appearance`, `revision`, `ready`, `resetOnDeath`, and `remainingMs` for a timed override. `ready` is the current owner's native presentation acknowledgement; it is not a promise that every observer has streamed the player. Setting identical options again extends the duration without respawning the body or changing its revision. ### Events ```lua AddEventHandler("onPlayerModelChanged", function(player, record, appearance, revision, reason) -- record is nil when restoring the original model. end) AddEventHandler("onPlayerModelReady", function(player, revision) -- Check this revision when coordinating an asynchronous operation. end) AddEventHandler("onPlayerModelFailed", function(player, revision, reason) -- The authoritative override is cleared; the original player remains usable. end) ``` These events are server-side. Change reasons include `set`, `reset`, `expired`, `died`, `resource_stopped`, `disconnected`, `projection_timeout` and native failure reasons. An acknowledgement for an old revision/connection is ignored. The `open77:playerModel:*` transport belongs to the platform; do not publish it from Lua. ## Client API | Function | Result | | --- | --- | | `GetPlayerModel(playerId?)` | current state table, `nil` for original model, or `nil, reason` | | `IsPlayerModelReady(playerId?)` | this client's projection readiness, or `nil, reason` | | `IsPlayerModelValid(record)` | record exists and is a Character record; `false, reason` otherwise | Aliases: `Open77.players.getModel`, `.isModelReady`, `.isModelValid`. Omitted/nil player ID selects the local player. Zero is not an explicit player ID. The client table contains `player`, `record`, `appearance`, `revision`, `entity`, `ready`, `status` and optional `failure`. `entity` is a local Open77 entity handle, not a network player ID; it changes when a body is recreated and must not be persisted or sent to another client as an identity. Use the network player ID. Statuses: `preparing`, `active`, `parked`, `not_streamed`, `failed`. Readiness and availability are different: `IsPlayerModelValid` does not load or animate a body and cannot prove its locomotion, seat or appearance compatibility. ```lua local model, error = GetPlayerModel() if error then print("Model state unavailable: " .. error) elseif model and model.ready then print(("Visible model: %s (revision %s)"):format(model.record, model.revision)) end ``` ## Replication and lifecycle - Selection is server-owned and scoped to the player's routing bucket. - Late join/reconciliation receives bounded, atomic paged snapshots. Older revisions and messages from another connection cannot restore stale models. - Streaming or a bucket change recreates the presentation from the current state. - Local impersonation requires third person. A conflicting forced first-person policy fails explicitly; normal perspective preference is retained for reset. - Invalid/unavailable native models and preparation failures must recover to the original body. An unacknowledged request times out after 20 seconds while the player is alive and gameplay-ready. - Models do not grant NPC stats, weapons, boss abilities, faction or hostility. Gameplay authority remains on the player and server. Common errors: `invalid_player`, `player_unavailable`, `player_not_ready`, `player_not_alive`, `invalid_record`, `invalid_appearance`, `invalid_duration`, `invalid_options`, `model_owned_by_other_resource`, `quota_exceeded`, `record_not_found`, `record_not_character`, `third_person_blocked`, `projection_timeout`, `state_unavailable`, and permission-denial strings. Native record preparation can also report `model_record_limit`, `proxy_record_unavailable`, `model_clone_failed`, `model_record_conflict`, `model_template_missing`, `model_flat_failed`, or `model_record_failed`. ## Integration checks Check the intended models with the movements, vehicle seats and animations used by your gamemode. Handle asynchronous preparation failures and always provide a reset action. See the [admin morph workflow](player-model-catalogue.md#in-game-admin-menu) for model selection and restoration. --- Source: https://open2077.net/docs/player-model-catalogue # Player morph catalogue Browse **6,582 `Character.*` records** extracted from Cyberpunk 2077 **2.31** with Phantom Liberty. Each ID can be passed to the player-model API; this catalogue is a discovery index, **not a guarantee that every record is playable**. The 86 internal `TEST.*` records are excluded because they are outside the API namespace. The website provides searchable records, categories, asset details and a Copy ID button. The [complete record list](https://github.com/Open2077/open77-base/blob/main/docs/generated/player-models-2.31.md) also lists every ID in one Markdown table for repository users and AI agents. ## Morph and restore ```lua -- Server resource manifest: -- permissions { "players.model.control", "players.model.read" } local ok, revision = SetPlayerModel(playerId, "Character.Rogue") if not ok then print("Morph refused: " .. tostring(revision)) end -- Optional explicit appearance: it must belong to the chosen template. -- SetPlayerModel(playerId, record, { appearance = "exact_appearance_name" }) -- When the roleplay/admin action ends: local restored, reason = ResetPlayerModel(playerId) ``` Wait for `onPlayerModelReady` before assuming the body is visible. A record may validate but fail to load its appearance or support a requested action. See [Player models](player-models.md) for ownership, events, permissions and failures. Custom `Character.*` records do not need to be added to this catalogue; install their assets on every observing client. ## Choosing a body | Starting point | Record | Model notes | | --- | --- | --- | | Rogue | `Character.Rogue` | Humanoid local/remote presentation and vehicle seating. | | Johnny | `Character.JohnnyNPC_Puppet_Photomode` | Humanoid local/remote presentation and vehicle seating. | | Adam Smasher | `Character.Smasher` | Special skeleton; vehicle poses can T-pose or intersect the car. | Quest, scene, player-body, child, animal and robot records can have incompatible rigs or behavior. Extraction labels describe database records, not supported player actions. The visual body does not grant NPC AI, faction, boss powers, health or weapons. Appearance names are not record IDs. The extracted database source is not a reliable per-record DLC indicator: `tweakdb_ep1.bin` contains base-game entries too. ## In-game admin menu Open `/admin` → **Self** → **Morph / original character**: - Search all records or browse categories, 20 results per page. - Confirm a record, or enter a custom `Character.*` ID directly. - Optionally set an appearance override, or clear it to use the record's default. - Choose **Unmorph — original character** to restore the current player appearance. Opening the menu requires `command.admin`. The server checks `command.admin.self.morph`, `command.admin.self.unmorph` and `command.admin.self.morph.search` separately. A read-only catalogue grant cannot morph anyone. These actions affect only the operator, not a supplied player ID. Morphing remains server-authoritative and replicated. The admin cannot replace or reset an override owned by another resource. Revoking the morph permission, death, disconnect or stopping the admin resource releases its own override. ## Downloads and reproducibility - [Searchable catalogue JSON](/data/npc-records-2.31.json): all 6,582 `Character.*` IDs and extracted metadata. - [Raw extraction CSV](/data/npc-records-2.31.csv): 6,668 entries, including the excluded internal records. - [Complete morph list, Markdown](/data/player-models-2.31.md): all accepted-namespace IDs in one file. The admin index and Markdown list are generated from the same CSV by `resources/system/open77_admin/tools/build-models.py`. Their source SHA-256 is recorded in the generated files. Run the generator with `--check` to detect drift. Neither the documentation nor the menu is a runtime allowlist. --- Source: https://open2077.net/docs/rp-animations # Role-play animations For authored walk/run graphs, stride synchronization and server-assigned movement styles, see [Custom locomotion](custom-locomotion.md). Play server-authoritative RP actions for inventory use, jobs and social gestures. Use upper-body profiles such as `smoke_walk`, `drink_walk` and `wave` while moving, or full-body workspots such as `smoke`, `phone` and `dance` for stationary actions. Requires matching client, server and RP archive support for the selected profile. Clip and prop compatibility can vary by body and appearance. Repeated reconnects can leave stale presentation objects or a camera hold; handle lifecycle failures. Furniture is not spawned automatically. Each profile selects compatible clips and either a workspot or an upper-body layer. Start with [walking actions and items](#upper-body-actions-for-jobs-and-inventory) for a minimal server Lua example. Arbitrary names from the general game inventory are not accepted by this system. See the [profile and clip catalogue](rp-animation-catalogue.md) for 76 profiles and 456 selectable clips. The `chair`, `lean` and `lie` profiles support anchored placement; see [Portable workspots](#portable-workspots-sit-lean-and-lie-anywhere). Male and female bindings are included, but not every clip is compatible with every body or proxy graph. To build a job action out of a profile, a prop attached to the hands and a progress bar, with complete server examples, see [Animated actions](animated-actions.md); a whole roleplay server built on these profiles is published as [the roleplay pack in Open2077/open77-resources](https://github.com/Open2077/open77-resources/tree/main/rp). ## How it works 1. **Choose a profile.** A resource or the Freeroam `/anim` menu selects an allowed profile and, optionally, one of its exact clip variants. A client requests an action for itself; a server resource can start one for a player it controls. 2. **The server owns the action.** It validates the player, resource ownership, options and duration, assigns a `playbackId`, and distributes state within the routing bucket. Store this ID when a delayed callback will stop the action. 3. **Clients present the accepted state.** For workspots, the local player temporarily uses the F7 third-person body; replicated bodies receive the same selected profile and step. Native mounting and profile changes are asynchronous. A native failure is reported separately from the accepted server request. Workspot profiles are stationary and cancel when the player moves. Layer profiles keep walking, running and turning available while animating the upper body. Entering a vehicle, death and combat can interrupt an action. Neither profile kind replaces the locomotion or combat controller. ## Upper-body actions for jobs and inventory Run [`open77_animations`](https://github.com/Open2077/open77-resources/tree/main/system/open77_animations) and [`open77_props`](https://github.com/Open2077/open77-resources/tree/main/system/open77_props) with matching client, server and archive support. The server runtime owns playback and temporary item lifetime; the native client animates the body and attaches the item. Your RP resource owns inventory validation, consumption, job permissions and rewards. ```lua -- open77.lua resource 'my_job_actions' server_script 'server.lua' permissions { 'players.animations.control' } ``` ```lua -- server.lua: call from your validated job or inventory handler. local function drink(playerId) local action, reason = Open77.animations.play(playerId, 'drink_walk', { loop = false, }) if not action then return nil, reason end return action.playbackId end ``` This plays one drink cycle with the default can while the player can move. The platform removes its temporary can when the action ends; no prop-spawn call or cleanup timer is required. It does not remove anything from the player's inventory or change thirst. Server acceptance does not prove that presentation succeeded. | Profile | Action | Default item | | --- | --- | --- | | `smoke_walk` | Smoke a cigarette | `Items.crowd_cigarette_i_stick` | | `cigar_walk` | Smoke a cigar | `Items.crowd_cigar` | | `drink_walk` | Drink from a can | `Items.crowd_soda_can_a` | | `bottle_walk` | Drink from a bottle | `Items.crowd_bottle_i_beer` | | `hold_item_walk` | Hold an item | `Items.crowd_soda_can_a` | | `call_walk` | Talk on the phone | `Items.crowd_talking_cellphone` | | `phone_walk` | Use the phone with both hands | `Items.crowd_cellphone` | | `wave`, `point`, `thumbsup` | Brief gesture | None | | `handsup_walk`, `handsback_walk`, `armscrossed_walk` | Maintain a pose while moving | None | Use `Open77.animations.list()` on the server to discover profiles supported by the installed build. Profiles with `kind='layer'` use the upper-body path. Select these by profile ID: a raw clip name shared with a workspot resolves to the stationary version. An `upperBody=true` option does not convert arbitrary clips. ### Animation-owned items: one server call | `options.item` | Behavior | | --- | --- | | Omitted | Use the layer profile's default item, if any. | | `'Items.crowd_soda_can_a'` | Select this native item. Server only; also requires `world.props`. | | `false` | Suppress the animation-owned item. An independently held item is preserved. | The item option is available on `play`, each `sequence` step, and `playClip` when it resolves to a layer. Workspots reject it with `item_requires_layer`. Invalid record syntax returns `invalid_item_record`; client requests for a custom record return `item_requires_server`. Clients may request `item=false`. Native items use the game's authored hand attachment. A valid record name does not guarantee that its size or grip suits a chosen animation. Custom mesh props remain available through [attachments](attachments.md). For native `Items.*`, `options.item` selects the record; it does not expose position or rotation adjustments. The native attachment supports zero offsets/rotations and unit scale only. For a custom mesh prop, the server can set `bone`, `offset` and `rotation` with `Open77.props.attach`, and use `item=false` on the animation. Player-facing adjustment controls must send requests to your server resource, which validates and applies them. Animation poses and mouth contact are separate from prop attachment and cannot generally be corrected by moving the prop alone. ### Item size and mouth contact The experimental contact adapter adjusts the arm during `drink_walk`, `bottle_walk`, `smoke_walk` and `cigar_walk`, using the item's visible geometry and the character's mouth. It preserves the authored grip. In first person, the arm keeps the held item visible at the lower right between sips and smoothly follows the camera during contact. `hold_item_walk` only holds the item. Matching client, server and animation assets are required; visual validation is incomplete. Omitting `itemContact` selects automatic contact estimation. For cans and bottles, the main mesh's bounds select the end along the item's authored local up direction (the can's rim). This remains the selected end while the container tilts; choosing whichever end is nearest the mouth could select the bottle's bottom. Smoking items use the end nearest the mouth. Bounds cannot identify every custom model's opening. For an unusual item, your inventory definition can supply a measured mouth point: ```lua -- Server manifest: players.animations.control, world.props -- itemDefinition belongs to your validated inventory/job configuration. local action, reason = Open77.animations.play(playerId, 'bottle_walk', { item = itemDefinition.record, itemContact = itemDefinition.mouthPoint, }) if not action then print(reason); return end -- mouthPoint is nil (automatic), or { x=..., y=..., z=... } for this model. ``` | `options.itemContact` | Behavior | | --- | --- | | Omitted | Estimate contact automatically from visible geometry. | | `{x,y,z}` | Contact point in metres in the native item's local frame. Missing axes default to zero; supplied axes must be finite and within ±2 metres. | | `false` | Disable mouth fitting. The first-person holding pose still keeps the item visible. | The option is server-only and requires `world.props`, including when set to `false`. Use it on `play`, on `playClip` when the clip resolves to a layer, or on each sequence step. Workspots reject it with `item_requires_layer`; client requests return `item_requires_server`. Malformed points, unknown fields and out-of-range coordinates return `invalid_item_contact` before replacing the current action. Omit the option on a subsequent action or step to restore automatic contact. Changing the point preserves the animation-owned prop ID when the item record stays the same. This adjusts the arm's contact target. It does not change the item's grip offset, consume inventory or make an incompatible shape fit the fingers. An independently owned held item keeps its own attachment settings: set `contact` on that native item's `Open77.props.attach` binding instead. Contact settings travel with the prop attachment; they are not fields of the animation playback snapshot. ### Hold, drink, then hold again For input-driven transitions, call `play` from the same resource when the player chooses an action. Keep the latest returned playback ID for delayed cancellation: ```lua -- Server manifest also needs world.props for an explicit item selection. local function setDrinkAction(playerId, drinking) local profile = drinking and 'drink_walk' or 'hold_item_walk' return Open77.animations.play(playerId, profile, { item = 'Items.crowd_soda_can_a', }) end -- setDrinkAction(playerId, false): hold. -- setDrinkAction(playerId, true): drink until the next input or cancellation. ``` Changing between these actions preserves the same prop ID while its item record stays the same. Each call returns a new playback ID. For an automatic timed chain: ```lua local action, reason = Open77.animations.sequence(playerId, { { profile = 'hold_item_walk', durationMs = 3000 }, { profile = 'drink_walk' }, -- One measured clip cycle for a layer step. { profile = 'hold_item_walk', durationMs = 3000 }, }, { loop = false }) if not action then print(reason); return end -- To cancel this particular action from the same resource: -- Open77.animations.stop(playerId, action.playbackId) ``` The default can is shared across the three steps and removed when the sequence finishes. With a custom item, specify `item` on each step. A sequence coordinates one player's actions; it does not automatically coordinate a handover between players. Use the existing [player interactions](player-interactions.md) API for carrying or escorting another player. An independently held item can outlive an animation through the server `Open77.heldItems.hold` / `release` API. Its owning resource must manage its release. Animation cleanup preserves that item and suppresses the default while the hand is occupied. The animation-owned item is cleaned up on completion, cancellation, death, disconnect, bucket changes and resource shutdown. See the public [RP examples and held-action guide](https://github.com/Open2077/open77-resources/blob/main/rp/docs/held-actions.md) for inventory, bar and phone integration. ### Third-person and first-person support Upper-body profiles animate the third-person body while locomotion continues. Selected smoking, drinking and phone poses also animate the neck/head; the hands-behind-back pose includes spine movement. Contact with the mouth and clothing can vary by body and appearance. First-person arm support is experimental and needs a compatible client and player animation archive. The can, bottle, cigarette, cigar and hold profiles have visible first-person hands/items on the tested female and male bodies. Third-person can contact reaches the lips on both tested bodies; third-person smoking contact has been checked on the female body. Items stay visible at the lower right between sips. Other appearances, custom shapes and remaining third-person combinations remain under validation; phone and other gestures do not have a first-person adapter. Third-person support does not imply first-person support for every profile. `hold_item_walk` remains active with its item during ground sprinting. Holding, drinking and holding again preserves the same item; completion removes the animation-owned item. Punching interrupts the layer and its item, and a later play creates a fresh presentation. Server acceptance alone does not establish that a particular body, item or animation phase rendered correctly. | Task | Client resource | Server resource | | --- | --- | --- | | Find profiles | `list(query?)`, `get(profileId)` via a Promise | `list(query?)`, `get(profileId)` synchronously | | Start an action | `request(profileId, options?)` for yourself | `play(playerId, profileId, options?)` | | Chain actions | `sequence(steps, options?)` | `sequence(playerId, steps, options?)` | | Stop | `cancel(playbackId?)` for yourself | `stop(playerId, playbackId?)` for this resource's action | | Place an action at a pose | -- | `playAt(playerId, profileId, position, yaw?, options?)`, torn down by `stopAt(playbackId)` | | Observe state | `state(playerId?)` via a Promise | `current(playerId)` synchronously | All methods above belong to `Open77.animations`. The older client `play(entity, rawClip)`, `playSelf`, `stop`, `stopSelf` and `current` are **local presentation APIs**, not the synchronized RP API. They must not be confused with server methods that have the same names. ## Installation and player commands Run the `open77_animations` resource on the server so every joining client receives its presentation service and catalogue. It is auto-started when discovered and is included in the Freeroam template. Other server templates can include the same resource without depending on Freeroam. The client needs an Open77 build containing the RP API and **Open77RP.archive**, alongside the matching base and mirror archives; copying Lua alone does not add native playback support. See the [content build guide](../scripts/animations/README.md). If an existing server profile uses an explicit `resources.load` list, add `"open77_animations"` to that list as well. `auto_start` does not override a resource-selection filter, and changing the Freeroam template does not update previously created server profiles. Keep the profile's other resources enabled. With the official chat package, players can use: ```text /anim /anim list /anim smoke /anim info smoke /anim smoke 2 /anim stop ``` `info` lists the exact clips and their 1-based variant numbers. Omitting a variant uses the profile's default, which need not be its first alphabetically sorted clip. Commands always target the authenticated caller, not a player ID in the arguments. `/anim` (or `/anim menu`) opens Freeroam's compact **Animations** library. It shares the existing WebUI surface but has its own layout: 76 families, 456 variants and 11 categories. Search by label, exact clip or common RP terms; save exact variants as favourites, revisit successfully started actions in Recent, or enable All variants. The browser renders at most 60 cards per page. Select a family and use the variant arrows, then **Play**, or double-click a card. Looping and timed 5/10/30-second playback are available. The menu releases input before the temporary TPP starts. **Stop** or `/anim stop` cancels the caller's action, including an in-flight UI request; moving interrupts workspot playback. Experimental variants may have body-specific presentation differences. Start/menu/list/info commands are limited to one every 500 ms per player. Other gamemodes can provide their own menu by receiving the server-sourced `open77_animations:menu` event. The platform resource does not depend on Freeroam. Without a menu provider, use the explicit text commands above. UI command events must not use `open77:animations:*`, which is reserved for authoritative protocol messages and rejected by `TriggerClientEvent`. ## Quick start: your first client action After enabling `open77_animations` on the server, create a resource such as `my_emotes` with these two files. Add your resource to the server's selected resources too; keep the existing gamemode, wardrobe and other entries intact. `open77.lua`: ```lua resource 'my_emotes' version '1.0.0' dependency 'open77_animations >=1.0.0' client_script 'client.lua' permissions { 'input.actions' } ``` `client.lua`. A client resource may `RegisterCommand` of its own — see the [FiveM compatibility page](fivem-compatibility.md) — but for an emote a key is the better trigger, which is what `input.actions` is for: ```lua RegisterKeyMapping('my_smoke', 'Smoke', 'K', function() CreateThread(function() local pending, dispatchError = Open77.animations.request('smoke', { loop = false, durationMs = 15000, }) if not pending then print(dispatchError); return end local playback, rejected = pending:await() if not playback then print(rejected); return end print('Server accepted action: ' .. playback.playbackId) end) end) ``` Join the world, stand still on foot, then press `K`. The request schedules 15 seconds of smoking; no manual F7 toggle is needed. Use `/anim stop` or move to cancel earlier. If using your own WebUI, release its input focus before requesting playback, as the Freeroam **Play & Close** button does. A message saying the server accepted the action is not a guarantee that native rendering succeeded. For an NPC interaction or other server-owned activity, use the server `play` example below and declare `players.animations.control` in that resource. Do not call the client Promise signature from a server script. ## Client Lua API Declare `dependency 'open77_animations'` in your client resource manifest. These methods are asynchronous facades over that resource's exports, not server Lua methods. They work in the same downloaded resource host as the service. A local bootstrap resource in a separate host cannot call across that boundary. | Method | Promise result | | --- | --- | | `list(query?)` | Matching profile definitions, copied for the caller | | `get(profileId)` | Profile definition, or nil if absent | | `request(profileId, options?)` | Server-accepted playback state for the local player | | `sequence(steps, options?)` | Server-accepted sequence for the local player | | `cancel(playbackId?)` | `true` when local cancellation was queued | | `state(playerId?)` | Last active canonical state; defaults to the local player | Call from a managed coroutine or event handler. There are two possible error stages: dispatch can return `nil, reason` instead of a Promise; `:await()` can return `nil, reason` from a rejected request or stopped resource. ```lua CreateThread(function() local pending, err = Open77.animations.request('smoke', { durationMs = 15000, loop = false, }) if not pending then print(err); return end local playback, rejected = pending:await() if not playback then print(rejected); return end print('Accepted RP action: ' .. playback.playbackId) Wait(4000) local stop, dispatchError = Open77.animations.cancel(playback.playbackId) if not stop then print(dispatchError); return end local cancelled, cancelError = stop:await() if not cancelled then print(cancelError) end end) ``` `request` shares server `play` options. `sequence` shares server sequence options below. Client calls cannot select another player. The facade does not require a presentation capability in the caller: only `open77_animations` uses the internal `animations.presentation` bindings. Do not call underscore-prefixed methods yourself. Accepted means **the server accepted the action**, not that a native device is already mounted. `cancel` stops local presentation immediately and sends a reliable request for that playback ID; `onPlayerAnimationChanged` reports the authoritative stop. Omitting the ID also abandons pending requests. Each client start carries a correlation ID in its authoritative state: an abandoned local action is suppressed even if the state broadcast or a snapshot arrives **before** the request response. Its exact playback ID is sent back for cancellation. Other players and unrelated server-started actions are not suppressed by that pending request. A late acceptance is cancelled even if its original caller timed out or its resource generation stopped. The 10-second timeout is enforced by the service, including when the caller has not resumed its Promise. At most 32 unanswered start requests are retained. These pre-response safeguards require the matching updated server and client resource; legacy states without a correlation ID can only be associated with a pending request when its response arrives. ```lua AddEventHandler('onPlayerAnimationChanged', function(playerId, state) -- On the client, state is a Lua table, not the server event's JSON string. print(playerId, state.active, state.reason) end) AddEventHandler('onAnimationPlaybackFailed', function(playerId, playbackId, reason) print('Native animation could not continue:', playerId, playbackId, reason) end) ``` `onAnimationResult(requestId, ok, error, value)` additionally reports receipt of a start/sequence response. Prefer the Promise for associating your own request with its result. These local notification events are observations, not authority for granting server rewards or inventory items. Late join and stream-in retain the server-selected profile, current step and playback ID. A recreated remote body receives the current action, not an old incarnation's handles. Snapshots replace visibility atomically after all pages arrive and never cross routing buckets. They are bounded by both the network's 48 KiB envelope and the client's 1,024-value JSON decoder. A maximum-size roster of 1,024 players with 16-step sequences fits within the 128-page assembly limit. Canonical state is committed atomically; snapshot change notifications drain in batches of 32 on subsequent service ticks. A newer live event supersedes a queued notification for that player. `state()` can therefore see the committed snapshot before every corresponding notification has been delivered. ## Server Lua API `Open77.animations` is provided by the server runtime. No resource export or `open77_animations` dependency is needed for the server methods below. Add the permissions your resource actually needs to its `open77.lua`: ```lua permissions { 'players.animations.control', 'players.animations.read', } ``` | Method | Result | Permission | | --- | --- | --- | | `list(query?)` | Array of matching profile definitions | None | | `get(profileId)` | Profile definition, or nil when absent | None | | `play(playerId, profileId, options?)` | Accepted playback state | `players.animations.control` | | `sequence(playerId, steps, options?)` | Accepted playback state | `players.animations.control` | | `stop(playerId, playbackId?)` | `true`, including when already stopped | `players.animations.control` | | `playAt(playerId, profileId, position, yaw?, options?)` | Accepted playback state with an `anchor` | `players.animations.control` | | `stopAt(playbackId)` | `true`, including when the handle no longer names a running action | `players.animations.control` | | `current(playerId)` | Playback state, or nil when inactive | `players.animations.read` | Failures return `nil, error`. Successful lookup of a missing profile or inactive player returns nil without an error. IDs are case-sensitive. Search is a case-insensitive substring match across ID, label and category; its maximum length is 128 characters. A search can match several profiles. ```lua local profile = Open77.animations.get('smoke') local matches = Open77.animations.list('social') local playback, err = Open77.animations.play(playerId, 'smoke', { loop = false, durationMs = 15000, }) if not playback then print('Cannot start animation: ' .. tostring(err)) return end -- Keep this ID if a timer or later callback will stop this particular action. local playbackId = playback.playbackId local stopped, stopError = Open77.animations.stop(playerId, playbackId) ``` ### Duration, clips and sequences `play` options are `clip`, `durationMs`, `loop` and `item`. The item option is for layer profiles only, as described above. Omit `clip` to select the profile default. A variant must belong to that exact profile. Unknown options, non-integer durations and cross-profile clips are rejected, not silently corrected. `loop` defaults to true. With that default, omitted or zero `durationMs` keeps the server action active until cancellation. With `loop = false`, the default duration is the measured clip length when supplied by the profile, otherwise 5,000 ms. One-shot layer gestures also use their measured duration when looping. A nonzero duration must be between 1,000 and 600,000 ms. Durations schedule server playback. The 5,000 ms fallback does not establish the native clip length. Layer profiles expose measured `clipDurationsMs`; workspot seeking and repetition have different presentation constraints. ```lua local playback, err = Open77.animations.sequence(playerId, { { profile = 'handsup', durationMs = 4000 }, { profile = 'clap', durationMs = 6000 }, { profile = 'think', durationMs = 8000 }, }, { loop = false }) ``` A sequence contains 1–16 steps. Each step accepts `profile`, `clip`, `item` and `durationMs`; its duration defaults to the measured clip length when available, otherwise 5,000 ms, and must be nonzero. The sum cannot exceed 600,000 ms. The only sequence option is `loop`, default false. Different profiles may appear in one sequence; native profile changes require asynchronous workspot exit/re-entry and are not promised to be seamless blends. ### Addressing a clip by name (`TaskPlayAnim`'s shape) Every other engine names an animation directly — FiveM's `TaskPlayAnim(dict, name, ...)` — and a caller that knows the clip should not have to know which action happens to carry it. `playClip` is that shape: ```lua -- Server. Same permission, readiness, ownership and duration rules as play(). local playback, err = Open77.animations.playClip( playerId, 'stand__dance__02__dancing__03', { durationMs = 15000, loop = false }) ``` ```lua -- Client, for the local player. local playback, err = Open77.animations.requestClip( 'stand__dance__02__dancing__03', { loop = true }):await() ``` Discovery is `clips` and `clip`, on both runtimes and with no capability required: ```lua for _, row in ipairs(Open77.animations.clips('dance') or {}) do print(row.profile, row.clip) -- dance stand__dance__02__dancing__03 end print(Open77.animations.clip('stand__dance__02__dancing__03').id) -- dance ``` The clip is resolved to its owning profile from the same generated catalogue on both halves, so `playClip(id, clip, opts)` and `play(id, profile, { clip = clip })` start the identical action and produce the identical playback state. Nothing new goes on the wire. #### What "raw playback" can and cannot mean here **Cyberpunk has no play-a-clip-by-name native.** A body plays an authored clip only after it is mounted into a *device* — an entity carrying a `workWorkspotResourceComponent` — and `SendJumpToAnimEnt` then jumps it to a node **inside that device's workspot tree**. The device and its tree are bound together when the archive is built, not at runtime. The addressable set includes the catalogue workspots and the shipped layer profiles. `Open77.animations.clips()` returns the installed build's supported profile/clip pairs. Select a layer by profile ID when its clip is also used by a workspot; clip-first lookup selects the full-body version for shared names. - Download the [clip-name inventory](/data/emote-animations.txt) (23,044 names) or the [workspot discovery index](/data/rp-workspots.json) (4,510 names across 494 vanilla workspots). These public files require no repository access and contain identifiers, not animation assets. The inventories can overlap and are **discovery lists, not playback allowlists**. Angle-bracket names in the workspot index are engine markers. A name absent from the installed playback catalogue is refused with `unknown_clip`. For usable actions, start with the [profile catalogue](rp-animation-catalogue.md) and `Open77.animations.clips()`. - The legacy client-local `Open77.animations.play(entity, clip)` looks like it takes any name. It does not: it mounts the body into the single generic device `cyberm\entities\workspot_anim.ent`, which binds exactly one vanilla workspot (`base\workspots\common\ground\generic__stand_cigar__stand_around__01.workspot`), so only that tree's nodes resolve. Anything else is a silent no-op. Adding a clip means adding a **device**: a new entry in `scripts/animations/rp-profiles.json`, a regenerated workspot and `.ent` from `scripts/animations/build-devices.ps1`, and a repacked `Open77.archive`. See [`scripts/animations/README.md`](../scripts/animations/README.md). #### Flags that do not exist FiveM's `TaskPlayAnim` flags have no workspot equivalent, and the service refuses them **by name** rather than with a blanket `invalid_options`, so a port can see which concept is missing instead of assuming a bad value: | Passed option | Reason returned | Why | | --- | --- | --- | | `upperBody` | `unsupported_option:upperBody` | Choose a shipped layer profile such as `smoke_walk`; this flag cannot convert a workspot. | | `blendIn`, `blendOut`, `blendMs` | `unsupported_option:blendIn` … | The engine *jumps* a mounted body to an authored node; it does not blend a clip in over milliseconds. | | `holdLastFrame` | `unsupported_option:holdLastFrame` | Not measured on 2.31. Unverified behaviour is not an API. | | `flags`, `dict`, `playbackRate` | `unsupported_option:` | No equivalent concept. | Supported options include `loop`, `durationMs`, `clip` on `play` or a sequence step, and `item` on layer actions as described above. #### Scenarios: what `playAt` covers and what it does not `TaskStartScenarioInPlace` on a *world* chair is still not offered, and the reason has not changed: Cyberpunk's scenario equivalent is a workspot, a workspot is only playable through a device bound to it at asset-build time, and nothing on 2.31 enumerates workspot-bearing world entities near a point -- the same wall that stopped the cover commands in an earlier wave. Mounting a player into the bench they are standing next to would need that bench's device handle, and there is no way to ask for it. What *is* shipped is the other half of that native, `TaskStartScenarioAtPosition`: Open77 brings its **own** device to the pose. See the next section. ### Portable workspots: sit, lean and lie anywhere Three profiles exist for one reason: the pose they carry is authored against a piece of furniture that will not be there. Each ships its own device and derived workspot in `Open77RP.archive`, like every other profile, and each is invisible -- the device is the chair, and it renders nothing. | Profile | Vanilla source | Body pose | | --- | --- | --- | | `chair` | `chair\generic__sit_chair_lean_back__sit_around__01.workspot` | seated at chair height, leaning back, hands on lap; 37 clips | | `lean` | `wall\generic__stand_wall_lean_back__stand_around__01.workspot` | standing, back against a wall, arms crossed; 4 clips | | `lie` | `bed\generic__lie_double_bed__lie_around__01.workspot` | lying on the back at mattress height; 2 clips | They are ordinary profiles: `play(playerId, 'chair')` sits the player down exactly where they stand, on nothing. What makes them *portable* is `playAt`, which moves the body to a pose the server chooses, spawns the device there and plays the posture on it -- the body arrives first, the chair appears under it, then it sits: ```lua -- open77.lua: permissions { 'players.animations.control' } -- A chair prop spawned with Open77.props has no workspot of its own. Put the -- posture on its seat, facing the way the chair faces. local chairPos, chairYaw = { x = -1441.2, y = 129.6, z = 18.05 }, 90.0 local placed, err = Open77.animations.playAt(playerId, 'chair', chairPos, chairYaw, { durationMs = 30000, loop = false, }) if not placed then print('cannot seat: ' .. tostring(err)); return end seated[playerId] = placed.playbackId -- the handle stopAt() takes -- Later, whoever sat them down: Open77.animations.stopAt(seated[playerId]) ``` `position` is `{ x, y, z }` (or a three-element array); `yaw` is degrees about Z, the same convention as `Open77.props.create`, and defaults to 0. `options` are exactly those of `play`: `clip`, `durationMs`, `loop`. The accepted state carries an `anchor = { x, y, z, yaw }` field, and its `playbackId` is the handle. Any catalogue profile accepts a pose, not only the three above: `playAt(id, 'smoke', barCounter, yaw)` walks a smoker to the counter. **Limits, stated plainly:** - **Reach is bounded at 5 m** from the player's current position, refused with `anchor_too_far`. A placement moves the body, and past furniture-distance that is a teleport dressed as an animation, reachable with the animation permission alone. Journeys belong to [`Open77.players.teleport`](server-api.md#moving-a-player), which costs `players.teleport` and settles the body; call it first, then `playAt`. - **Placement is server-controlled.** `playAt` moves the player to the anchor without a fade, using its yaw and the normal readiness/settle checks. The posture is published to clients and its duration starts only after arrival. `onPlayerAnimationChanged` reports acceptance before arrival; this is not a rendering acknowledgement. - **The move watchdog is measured against the anchor**, not the starting point, and it is armed five seconds after acceptance. A body that never reaches its anchor in that window ends with reason `anchor_unreached` (the placement channel refused or the client never landed); one that arrived and then left ends with `moved`, as before. A placement the channel cannot even dispatch is refused at once with `anchor_move_refused` (the body is not ready, not alive, or in a vehicle). - **The pose is the caller's problem.** `z` is where the *device* goes, and the authored clip plays relative to it: for `chair` that is the floor under the seat, for `lie` the floor under the bed. Put a `chair` at seat height and the body floats. - **Nothing is enumerated.** `playAt` does not find chairs; it accepts a pose the server already knows -- a prop it spawned, a point from its own furniture table. - **Acceptance is server authority, not proof.** The client still mounts the anchored device with `gameWorkspotSlidingBehaviour.PlayAtResourcePosition` (runtime-tunable: `emote.tune anchorslide 0|1|2`, `emote.tune anchorslidetime `), but with the body already on the anchor the slide is zero either way. Observe `onPlayerAnimationChanged` and the `anchor_unreached` reason, not the return value. - `stopAt` on a handle whose action already ended succeeds, exactly as `stop` on an idle player does; another resource's handle is refused with `animation_owned`. Resource stop, restart and disposal tear placed actions down like every other. ### Playback state Accepted playback and `current` expose these fields: | Field | Meaning | | --- | --- | | `epoch` | Server animation-service generation | | `revision` | Monotonically increasing state revision within that epoch | | `playerId`, `playbackId` | Player and this specific accepted action | | `clientRequestId` | Internal client-start correlation; empty for server API/command actions. Not an ownership token or a value to pass to `cancel` | | `active`, `reason` | Whether active and the transition/cancellation reason | | `bucket` | Routing bucket in which this action began | | `steps`, `loop` | Validated profile/clip/duration sequence and repeat flag | | `startedAtMs`, `serverTimeMs` | Server monotonic timestamps, not wall-clock dates | | `cycle`, `step` | Zero-based current repetition and step | | `elapsedMs`, `remainingMs` | Timing within the current step; indefinite playback has no finite completion | ### Ownership, cancellation and events A resource controls only the actions started by its own VM generation. A new action from that owner replaces its old action with a new playback ID. Another resource cannot replace or stop it. Resource stop/restart/disposal cancels its actions; a replacement VM does not inherit the old owner's lease. Pass `playbackId` to delayed stops. A stop for an older action returns `stale_playback` and leaves the replacement running. Omitting it stops the current action owned by the calling resource. The player's self-cancellation network path is allowed to stop their own server-controlled animation. The server cancels on disconnect, loss of readiness, death, vehicle occupancy, routing-bucket changes and completion of a non-looping sequence. Movement beyond 0.5 m from the start cancels stationary workspots; layer profiles allow movement. The client reports native playback failures and interruptions through self-cancellation. Native interruption handling remains experimental. ```lua AddEventHandler('onPlayerAnimationChanged', function(playerId, stateJson) local state = json.decode(stateJson) if not state.active then print(('Player %s stopped: %s'):format(playerId, state.reason)) end end) ``` The event currently passes a player ID string and a JSON state string. State messages are restricted to players in the same routing bucket. The reserved `open77:animations:` event namespace belongs to the platform; resources must use the API, not forge authoritative events with `TriggerClientEvent`. Common errors include `unknown_profile`, `invalid_clip`, `invalid_options`, `invalid_duration`, `invalid_sequence`, `invalid_step`, `sequence_too_long`, `player_unavailable`, `player_not_ready`, `player_not_alive`, `player_in_vehicle`, `position_unavailable`, `animation_owned`, `stale_playback`, `resource_stopping` and `permission_denied:`. `playAt` adds `invalid_anchor` and `anchor_too_far`; `stopAt` adds `invalid_playback`. A placed action can also end with reason `anchor_unreached`. Client-side errors also include `export_resource_unavailable`, `player_not_ready`, `network_unavailable`, `too_many_requests`, `request_timeout`, `cancelled`, `session_changed`, `invalid_response`, and `resource_stopped`. Internal native failures are reported separately through `onAnimationPlaybackFailed`. ## Local view and development diagnostics The workspot local path requests third person temporarily and reuses the F7 presentation body. It waits for that body to be active before starting the workspot; its walking, aiming and combat presentation stand down while the workspot owns it. Stopping releases the temporary animation-camera priority immediately. Returning to first person restores the real player before hiding the animation proxy; an existing F7 or resource-owned third-person request is preserved. Native workspot detachment and safe device cleanup continue independently in the background. An already-playing local RP pose uses the native instant exit when cancelled; F7 locomotion resumes after confirmed detachment, without waiting for the separate callback-lifetime quarantine. This avoids leaving the visible body behind when the player starts moving. Pending mounts retain conservative cleanup. The player's stored first/third-person preference is not overwritten. Native safety states still take priority, and a forced-first-person server policy is refused. Developer bridge commands, for local rendering experiments only: ```text emote.profile player smoke emote.state player emote.stop player ``` These commands do not broadcast RP state. Existing client `Open77.animations.play(entity, rawClip)` and `playSelf` remain legacy local APIs, not the synchronized request API. Do not use them to implement RP replication. Native local failures retain their `status` and diagnostic `detail` in `emote.state player` after the temporary view has been released, until a new profile request or world exit. This history does not mean the body is still playing or mounted. Details distinguish proxy readiness, player movement/stance, camera policy and workspot playback failures. The native log records the same failure once; the downloaded resource also logs `RP playback failed` when it emits `onAnimationPlaybackFailed`. Observe the downloaded resource's events in its resource host, not a separate bootstrap host. --- Source: https://open2077.net/docs/custom-locomotion # Custom locomotion Build walking and running styles from authored animations, then assign them to players with server Lua. Open77 blends the selected graph on remote player bodies and the local third-person body, including male and female puppets. Use client and server **2.31.21+op77.141 or later** for the API and transition behavior described here. You also need a locomotion resource exported by **Gta2CPPuppet Studio**. Animation packages are supplied by the resource author; choosing a style ID does not download a built-in animation bank. ## Build and install a package 1. In Studio, import GTA clips or GLB animations authored on the exported Cyberpunk rig. 2. Create states, transition conditions and blends. Use a one-dimensional blend for speed or a two-dimensional blend for movement direction. 3. Preview the graph on both Cyberpunk puppets. Check idle, starting, stopping, turning, walking and running; tune stride timing as described below. 4. Choose **Export Open77** and copy the entire exported resource into the server's resource directory. 5. Enable the resource in the server configuration. Declare it as a dependency of the resource that assigns styles. 6. Connect through the launcher so it installs the preload archive before starting Cyberpunk. An export has this structure; archive filenames depend on the package: ```text open77_locomotion_my_walk/ open77.lua register.lua package.json graph.locomotion.json dist/ .zip ``` The generated manifest declares the shared registration script, graph files, `preload_mod` archive and `locomotion.register` permission. Keep the metadata, graph and archive together as one export. Re-export after editing animations or the graph so their revision and native bindings remain consistent. **Adding or changing the native archive requires a game restart for clients already running.** Restarting the Lua resource alone cannot mount the new native assets. See [Mods and custom assets](mods.md) for preload distribution and [Server resources](server-resources.md) for configuration. ## Assign a style The package's resource name and style ID are different. In this example, the resource is `open77_locomotion_my_walk` and the exported style ID is `my_walk`. Replace both with your own export's names. ```lua -- open77.lua: the gameplay resource that selects player styles. resource 'my_walk_controller' server_script 'server.lua' dependency 'open77_locomotion_my_walk' permissions { 'locomotion.control', 'locomotion.read' } ``` ```lua -- server.lua: call from your validated gameplay handler after the player is ready. local function applyWalkStyle(playerId) local ok, revisionOrReason = Open77.locomotion.set(playerId, 'my_walk', { durationMs = 60000, resetOnDeath = true, }) if not ok then return nil, revisionOrReason end return revisionOrReason end local function readWalkStyle(playerId) local selected, reason = Open77.locomotion.get(playerId) if reason then return nil, reason end return selected -- nil means this player has no custom style assigned. end local function clearWalkStyle(playerId) return Open77.locomotion.reset(playerId) end ``` The player must be authenticated, ready and alive when calling `set`. Validate the gameplay action on the server before assigning a style. Clients register package assets; they cannot assign or reset player styles. ## Lua API | Call | Runtime | Permission | Success | Failure | | --- | --- | --- | --- | --- | | `Open77.locomotion.register(path)` | Client and server | `locomotion.register` | Style ID string | `nil, reason` | | `Open77.locomotion.set(playerId, styleId, options?)` | Server | `locomotion.control` | `true, revision` | `false, reason` | | `Open77.locomotion.get(playerId)` | Server | `locomotion.read` | Assignment table, or `nil` when none is assigned | `nil, reason` | | `Open77.locomotion.reset(playerId)` | Server | `locomotion.control` | `true`, including when no assignment exists | `false, reason` | ### Registration The exported `register.lua` runs on both runtimes: ```lua local style, reason = Open77.locomotion.register('package.json') assert(style, 'Locomotion package registration failed: ' .. tostring(reason)) ``` Paths are relative to the registering resource. The server catalogs the package identity; the client validates the complete graph, clip selectors, durations, four native variants and mounted resources. Registration does not assign the style to anyone. Style IDs are unique across resources. Generations of the same resource can register identical package content; conflicting content under the same revision is rejected by the client. ### Assignment options | Option | Type | Default | Meaning | | --- | --- | --- | --- | | `durationMs` | Integer, `0..86400000` | `0` | Duration in milliseconds; `0` has no expiry. | | `resetOnDeath` | Boolean | `false` | Remove the assignment when the player dies. | Unknown options and incorrect types are rejected. Setting the same style, package revision and death policy again renews the duration while keeping the assignment revision and presentation body. `get` returns `player`, `style`, `packageRevision`, `revision` and `resetOnDeath`, plus `remainingMs` when the assignment expires. `packageRevision` identifies the exported package; `revision` identifies the server assignment. This result describes server state, not successful rendering on every client. ### Ownership and change events The resource generation that calls `set` owns the assignment. Another resource cannot replace or reset it. Stopping the assigning resource, disconnecting the player or removing the package's last registration releases the assignment. Death removes it when `resetOnDeath` is enabled. Updates follow routing buckets. Clients joining the session receive the current selections, including their package and assignment revisions. Listen for changes locally on the server: ```lua AddEventHandler('onPlayerLocomotionChanged', function(playerId, style, packageRevision, revision, reason) print(('Player %s locomotion: %s (%s), revision %s'):format( tostring(playerId), style or 'native', reason, tostring(revision))) end) ``` `style` is `nil` on reset. Reasons are `set`, `reset`, `expired`, `died`, `disconnected`, `resource_stopped` and `package_stopped`. ## Graph inputs and native animation The runtime supplies these parameters when they exist in the authored graph: | Parameter | Type | Value | | --- | --- | --- | | `speed` | Float | Rendered planar speed, in metres per second. | | `directionX` | Float | Lateral component of the unit movement direction. | | `directionY` | Float | Forward component of the unit movement direction. | | `grounded` | Boolean | Effective ground contact, including the native air driver's short contact bridge. | | `crouched` | Boolean | Observed crouch state. | | `sprinting` | Boolean | Observed sprint state. | | `mounted` | Boolean | `false` while the custom layer is eligible. | Direction uses the rendered body's heading. Extra parameters retain their authored defaults; package version 1 has no general Lua API for setting parameters or firing triggers. The layer changes animation presentation. First-person camera motion, appearance, collision, movement speed and network movement keep their existing behavior. NPC model projections use their own graphs and take precedence over custom player locomotion. Vehicles, workspots, airborne movement, traversal, swimming and combat suppress the custom layer. A graph's `native` state also returns control to the native pose. Observations older than 750 ms release the layer; drawing more frames does not refresh an old movement observation. ## Smooth transitions and stride timing Give ordinary movement transitions a positive duration to use an eased crossfade. A zero-duration edge is an immediate cut. When a transition is interrupted, its complete outgoing mixture remains visible and its clips keep playing, preserving continuity through the next fade. Speed and direction are damped for animation blending. State conditions still receive observed speed immediately, so starting and stopping need not wait for the blend filter. Returning from native animation fades the custom layer in over 0.20 seconds; an actual jump releases it immediately. For looping clips, select the clip in Studio and set: | Field | Range | Default | Purpose | | --- | --- | --- | --- | | `cycles` | Integer `1..64` | `1` | Number of complete left/right strides in the recording. | | `phaseOffset` | `0` inclusive to `1` exclusive | `0` | Shift the gait phase; `0.5` swaps the leading foot. | Count the strides in each source recording, then align the leading foot between walk and run. Synchronized transitions preserve a shared cadence throughout the fade. Non-looping states play their full clip and ignore these two fields. Tune transition durations for your clips and movement speeds. The runtime does not generate missing start/stop animations or foot locking, so crossfades alone cannot correct mismatched stride lengths or an unsuitable animation. ### Graph limits | Element | Maximum | | --- | --- | | Parameters / clips / states | 64 / 128 / 128 | | Transitions / conditions per transition | 512 / 16 | | Points per blend | 32 | | Clip samples per state / during a crossfade | 4 / 8 | An interrupted transition waits if the outgoing mixture plus the new state would exceed eight samples. The 2D blend uses the four nearest points with normalized inverse-square distance weights; its interpolation differs from Unity's blend trees. ## Errors and troubleshooting | Result or symptom | Action | | --- | --- | | `permission_denied:locomotion.register`, `permission_denied:locomotion.control`, `permission_denied:locomotion.read` | Add the corresponding permission to the resource that makes the call. | | `locomotion_package_unavailable` | Start the exported package and use its style ID, not its resource folder name. | | `locomotion_archive_missing_restart_required` | Install the matching preload archive through the launcher, then restart the game. | | `locomotion_owned_by_other_resource` | Let the resource owning the assignment reset it; use distinct style IDs for independently registered packages. | | `player_unavailable`, `player_not_ready`, `player_not_alive` | Wait until the player is connected, ready and alive before assigning. | | `invalid_argument`, `invalid_options`, `invalid_duration` | Check argument types, option names and duration bounds. | | `invalid_package`, `invalid_package_json`, `invalid_graph_json` | Re-export and deploy the complete package; inspect the runtime log for the rejected asset. | | `locomotion_package_limit` | Reduce registered packages; the server catalog allows 256 registrations. | | Assignment exists but native locomotion remains | Check the client's registration result, installed archive and current pose owner; test outside vehicles, combat and other native interruptions. | Keep the returned reason in logs; these are common failures, not an exhaustive list. ## Verify your animation in game Check both male and female bodies, the local third-person view and a second client's view. Exercise idle-to-walk, walk-to-run, turns, stops, rapid transition reversals, jump/land, vehicles and workspots. Verify reset, resource stop and late join with the package installed. The [debug runtime](debug-runtime.md) self-view report exposes `customStyle`, `customState`, `customWeight`, `customTime`, `customSamples`, `customFrom`, `customMix` and `customBlend` (clip index, time and weight). Remote movement reports expose the corresponding evaluator state. These values help explain blending; inspect the rendered body as well. Studio checks rigs, cooked graph/entity roundtrips and packed resources during export. Its receipt leaves `runtimeValidated=false` because an offline export cannot validate rendering in the running game. For gestures, held items and stationary actions alongside movement, see [Role-play animations](rp-animations.md). --- Source: https://open2077.net/docs/rp-animation-catalogue # RP animation catalogue Use the [role-play animation API](rp-animations.md) with a matching client, server and RP archive. This catalogue lists profile IDs, clips, source workspots, props and placement requirements. Asset bindings do not guarantee correct presentation for every clip or body; treat experimental variants accordingly. Each action uses an Open77 workspot derived from its vanilla source, not the historical single cigar device. Selectable clips have isolated loop containers; authored rig, prop and exit data are preserved. Durations and loop timing are not inferred from clip names. All actions are stationary. | ID | Action | Category | Placement | Expected prop | | --- | --- | --- | --- | --- | | `give` | Offer an item | interactions | standing | None | | `examine` | Kneel and examine | interactions | ground | None | | `wounded` | Wounded seated breathing | interactions | ground | None | | `smoke` | Smoke a cigarette | consumables | standing | cigarette | | `cigar` | Smoke a cigar | consumables | standing | cigar | | `drink` | Drink from a can | consumables | standing | can | | `phone` | Use a phone | social | standing | phone | | `dance` | Dance | dance | standing | None | | `handsup` | Hands up | gestures | standing | None | | `meditate` | Meditate | relaxation | standing | None | | `sit` | Sit on the ground | relaxation | ground | None | | `clap` | Applaud | gestures | standing | None | | `cry` | Cry | emotions | standing | None | | `think` | Think | emotions | standing | None | | `stretch` | Stretch | relaxation | standing | None | | `chair` | Sit as if on a chair | postures | chair | None | | `lean` | Lean back against a wall | postures | wall | None | | `lie` | Lie down | postures | bed | None | | `armscrossed` | Arms crossed | postures | standing | None | | `armscrossed2` | Arms crossed, relaxed | postures | standing | None | | `handsback` | Hands behind back | postures | standing | None | | `handships` | Hands on hips | postures | standing | None | | `handsfront` | Hands in front | postures | standing | None | | `onehip` | Hand on hip | postures | standing | None | | `idle` | Casual standing | postures | standing | None | | `wait` | Wait around | postures | standing | None | | `bored` | Bored | emotions | standing | None | | `fidget` | Fidget with hands | gestures | standing | None | | `rubhands` | Rub hands | gestures | standing | None | | `sad` | Feeling down | emotions | standing | None | | `nervous` | Nervous | emotions | standing | None | | `angry` | Angry gestures | emotions | standing | None | | `cheer` | Cheer | gestures | standing | None | | `cheer2` | Cheer, folded arms | gestures | standing | None | | `crowdwave` | Crowd wave | gestures | standing | None | | `preach` | Speak to a crowd | social | standing | None | | `calmdown` | Calm down | gestures | standing | None | | `order` | Order food | social | standing | None | | `drunk` | Drunk | emotions | standing | None | | `paranoid` | Paranoid | emotions | standing | None | | `taichi` | Tai chi | relaxation | standing | None | | `headphones` | Dance to headphones | dance | standing | headphones | | `call` | Phone call | social | standing | phone | | `call2` | Phone call, hand on hip | social | standing | phone | | `phonecheck` | Check phone | social | standing | phone | | `record` | Record on phone | social | standing | phone | | `bottle` | Drink from a bottle | consumables | standing | bottle | | `takeout` | Eat takeout | consumables | standing | takeout | | `smokefolded` | Smoke, arm folded | consumables | standing | cigarette | | `tablet` | Use a tablet | work | standing | tablet | | `tablet2` | Work on tablet | work | standing | tablet | | `tablet3` | Tablet in one hand | work | standing | tablet | | `repair` | Kneel and repair | work | ground | screwdriver | | `mechanic` | Kneel with a tool | work | ground | tool | | `scavenge` | Scavenge | work | ground | None | | `kneelcry` | Cry on knees | emotions | ground | None | | `cower` | Cower | emotions | ground | None | | `arrested` | Lie face down | postures | ground | None | | `sitafraid` | Sit with knees held | postures | ground | None | | `sitsmoke` | Smoke on the ground | consumables | ground | cigarette | | `laptop` | Use laptop on ground | work | ground | laptop | | `handpan` | Play a handpan | music | ground | handpan | | `leanrelaxed` | Lean against wall, relaxed | postures | wall | None | | `leanleft` | Lean on left side | postures | wall | None | | `wallcall` | Phone call against wall | social | wall | phone | | `wallsmoke` | Smoke against wall | consumables | wall | cigarette | | `seated` | Sit upright | seated | chair | None | | `seated2` | Sit, relaxed hands | seated | chair | None | | `seatedcheer` | Cheer while seated | seated | chair | None | | `seatedsleep` | Doze in a chair | seated | chair | None | | `seatedstretch` | Stretch while seated | seated | chair | None | | `seatedthink` | Think while seated | seated | chair | None | | `guitar` | Play guitar | music | chair | guitar | | `barlean` | Lean on a counter | postures | counter | None | | `barsit` | Sit at a bar | seated | barstool | None | | `bedsidelie` | Lie on side | relaxation | bed | None | ## Offer an item (`give`) Workspot: `base\workspots\common\ground\generic__stand_ground_item__give__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\give.workspot`. Default clip: `stand__2h_on_sides__01__to__stand__rh_item__01__turn0__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__to__stand__rh_item__01__turn0__01` ## Kneel and examine (`examine`) Workspot: `base\workspots\common\ground\generic__kneel_ground__inspect__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\examine.workspot`. Default clip: `kneel__rk_on_ground__01__inspect_ground__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `kneel__rk_on_ground__01__inspect_ground__01` ## Wounded seated breathing (`wounded`) Workspot: `base\workspots\common\ground\generic__sit_ground_wall_lean_back__wounded__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\wounded.workspot`. Default clip: `sit_ground_lean180__rh_on_belly__01__deep_breath__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_ground_lean180__rh_on_belly__01__deep_breath__01` ## Smoke a cigarette (`smoke`) Workspot: `base\workspots\common\ground\generic__stand_ground_cigarette__smoke__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\smoke.workspot`. Default clip: `stand__rh_cigarette__01__smoke__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_cigarette__01__drop_ash__01` - `stand__rh_cigarette__01__drop_ash__04` - `stand__rh_cigarette__01__look_around__01` - `stand__rh_cigarette__01__look_around__02` - `stand__rh_cigarette__01__shuffle__01` - `stand__rh_cigarette__01__shuffle__02` - `stand__rh_cigarette__01__shuffle__04` - `stand__rh_cigarette__01__smoke__01` - `stand__rh_cigarette__01__smoke__02` - `stand__rh_cigarette__01__smoke__03` - `stand__rh_cigarette__01__wipe_forehead__01` ## Smoke a cigar (`cigar`) Workspot: `base\workspots\common\ground\generic__stand_cigar__stand_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\cigar.workspot`. Default clip: `stand__rh_cigar__weight_right__01__smoke__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_cigar__weight_right__01__look_left__01` - `stand__rh_cigar__weight_right__01__scratch_ball__01` - `stand__rh_cigar__weight_right__01__scratch_nose__01` - `stand__rh_cigar__weight_right__01__smoke__01` - `stand__rh_cigar__weight_right__01__smoke__02` - `stand__rh_cigar__weight_right__01__smoke__03` ## Drink from a can (`drink`) Workspot: `base\workspots\common\ground\generic__stand_ground_can__drink__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\drink.workspot`. Default clip: `stand__rh_can__01__drink__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_can__01__drink__01` - `stand__rh_can__01__drink__03` - `stand__rh_can__01__drink__04` - `stand__rh_can__01__shuffle__01` - `stand__rh_can__01__spill__01` - `stand__rh_can__01__spill__02` ## Use a phone (`phone`) Workspot: `base\workspots\common\ground\generic__stand_ground__phone_use__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\phone.workspot`. Default clip: `stand__2h_phone__03__tap_phone__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_phone__03__shuffle__01` - `stand__2h_phone__03__tap_phone__01` ## Dance (`dance`) Workspot: `base\workspots\common\ground\generic__stand_ground__dance__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\dance.workspot`. Default clip: `stand__dance__02__dancing__02`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__dance__02__dancing__02` - `stand__dance__02__dancing__03` - `stand__dance__02__dancing__04` - `stand__dance__02__dancing__05` - `stand__dance__02__dancing__07` - `stand__dance__02__dancing__08` ## Hands up (`handsup`) Workspot: `base\workspots\common\ground\generic__stand_ground__hands_up__03.workspot`. Open77 playback workspot: `cyberm\workspots\rp\handsup.workspot`. Default clip: `stand__2h_up__03__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_up__03__look_around__01` - `stand__2h_up__03__look_left__01` - `stand__2h_up__03__look_right__01` - `stand__2h_up__03__shuffle__01` ## Meditate (`meditate`) Workspot: `base\workspots\common\ground\generic__stand_ground__meditate__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\meditate.workspot`. Default clip: `stand__meditate__01__breathe__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__meditate__01__breathe__01` - `stand__meditate__01__breathe__02` - `stand__meditate__01__look_left__01` - `stand__meditate__01__raise_hands__01` - `stand__meditate__01__shuffle__01` ## Sit on the ground (`sit`) Workspot: `base\workspots\common\ground\generic__sit_ground_lean_front__sit_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\sit.workspot`. Default clip: `sit_ground__2h_elbow_on_knees__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_ground__2h_elbow_on_knees__01__shuffle__01` - `sit_ground__2h_elbow_on_knees__01__shuffle__02` - `sit_ground__2h_elbow_on_knees__01__shuffle__03` - `sit_ground__2h_elbow_on_knees__01__shuffle__04` ## Applaud (`clap`) Workspot: `base\workspots\common\ground\generic__stand_ground__cheer__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\clap.workspot`. Default clip: `stand__2h_on_sides__01__2h_clap__happy__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__2h_clap__broad__01` - `stand__2h_on_sides__01__2h_clap__happy__01` - `stand__2h_on_sides__01__2h_clap__narrow__01` ## Cry (`cry`) Workspot: `base\workspots\common\ground\generic__stand_ground__cry__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\cry.workspot`. Default clip: `stand__rh_on_forehead__01__cry__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_on_forehead__01__blow_nose__02` - `stand__rh_on_forehead__01__cry__01` - `stand__rh_on_forehead__01__cry__02` - `stand__rh_on_forehead__01__rub_tears__01` ## Think (`think`) Workspot: `base\workspots\common\ground\generic__stand_ground__think__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\think.workspot`. Default clip: `stand__rh_on_chin__01__rub_chin__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_on_chin__01__rub_chin__01` - `stand__rh_on_chin__01__rub_forehead__ 01` - `stand__rh_on_chin__01__shuffle__01` - `stand__rh_on_chin__01__shuffle__02` ## Stretch (`stretch`) Workspot: `base\workspots\common\ground\generic__stand_ground__stretch__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\stretch.workspot`. Default clip: `stand__2h_on_sides__01__stretch_arms__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__stretch_arms__01` - `stand__2h_on_sides__01__stretch_arms__02` - `stand__2h_on_sides__01__stretch_arms__03` - `stand__2h_on_sides__01__stretch_arms__04` - `stand__2h_on_sides__01__stretch_muscle_01` - `stand__2h_on_sides__01__stretch_muscle_02` - `stand__2h_on_sides__01__stretch_muscle_03` - `stand__2h_on_sides__01__stretch_muscle_04` - `stand__2h_on_sides__01__stretch_muscle_05` - `stand__2h_on_sides__01__stretch_muscle_06` - `stand__2h_on_sides__01__stretch_muscle_07` - `stand__2h_on_sides__01__stretch_muscle_08` - `stand__2h_on_sides__01__stretch_muscle_09` - `stand__2h_on_sides__01__stretch_muscle_10` - `stand__2h_on_sides__01__stretch_neck__01` ## Sit as if on a chair (`chair`) Workspot: `base\workspots\common\chair\generic__sit_chair_lean_back__sit_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\chair.workspot`. Default clip: `sit_chair_lean180__2h_on_lap__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair_lean180__2h_on_lap__01__deep_breath__01` - `sit_chair_lean180__2h_on_lap__01__long__01` - `sit_chair_lean180__2h_on_lap__01__rub_lips__01` - `sit_chair_lean180__2h_on_lap__01__rub_lips__02` - `sit_chair_lean180__2h_on_lap__01__rub_lips__03` - `sit_chair_lean180__2h_on_lap__01__rub_lips__04` - `sit_chair_lean180__2h_on_lap__01__rub_temples__01` - `sit_chair_lean180__2h_on_lap__01__rub_temples__02` - `sit_chair_lean180__2h_on_lap__01__rub_temples__03` - `sit_chair_lean180__2h_on_lap__01__scratch_ear__02` - `sit_chair_lean180__2h_on_lap__01__scratch_ear__03` - `sit_chair_lean180__2h_on_lap__01__scratch_ear__04` - `sit_chair_lean180__2h_on_lap__01__scratch_ear__05` - `sit_chair_lean180__2h_on_lap__01__scratch_ear__06` - `sit_chair_lean180__2h_on_lap__01__scratch_face__01` - `sit_chair_lean180__2h_on_lap__01__scratch_face__02` - `sit_chair_lean180__2h_on_lap__01__scratch_face__03` - `sit_chair_lean180__2h_on_lap__01__scratch_face__04` - `sit_chair_lean180__2h_on_lap__01__scratch_neck__01` - `sit_chair_lean180__2h_on_lap__01__scratch_neck__02` - `sit_chair_lean180__2h_on_lap__01__scratch_neck__03` - `sit_chair_lean180__2h_on_lap__01__scratch_neck__04` - `sit_chair_lean180__2h_on_lap__01__shuffle__01` - `sit_chair_lean180__2h_on_lap__01__shuffle__02` - `sit_chair_lean180__2h_on_lap__01__shuffle__03` - `sit_chair_lean180__2h_on_lap__01__shuffle__04` - `sit_chair_lean180__2h_on_lap__01__shuffle__05` - `sit_chair_lean180__2h_on_lap__01__shuffle__06` - `sit_chair_lean180__2h_on_lap__01__shuffle__07` - `sit_chair_lean180__2h_on_lap__01__shuffle__08` - `sit_chair_lean180__2h_on_lap__01__sigh__01` - `sit_chair_lean180__2h_on_lap__01__yawn__01` - `sit_chair_lean180__2h_on_lap__01__yawn__02` - `sit_chair_lean180__2h_on_lap__01__yawn__03` - `sit_chair_lean180__2h_on_lap__01__yawn__04` - `sit_chair_lean180__2h_on_lap__01__yawn__05` - `sit_chair_lean180__2h_on_lap__01__yawn__06` ## Lean back against a wall (`lean`) Workspot: `base\workspots\common\wall\generic__stand_wall_lean_back__stand_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\lean.workspot`. Default clip: `stand_wall_lean180__arms_crossed_front__01__shuffle__02`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand_wall_lean180__arms_crossed_front__01__look_around__01` - `stand_wall_lean180__arms_crossed_front__01__shuffle__02` - `stand_wall_lean180__arms_crossed_front__01__shuffle__03` - `stand_wall_lean180__arms_crossed_front__01__shuffle__04` ## Lie down (`lie`) Workspot: `base\workspots\common\bed\generic__lie_double_bed__lie_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\lie.workspot`. Default clip: `lie_bed_270__sleep__01__deep_breathing__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `lie_bed_270__sleep__01` - `lie_bed_270__sleep__01__deep_breathing__01` ## Arms crossed (`armscrossed`) Workspot: `base\workspots\common\ground\generic__stand_ground_arms_crossed__stand_around__02.workspot`. Open77 playback workspot: `cyberm\workspots\rp\armscrossed.workspot`. Default clip: `stand__arms_crossed_front__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__arms_crossed_front__01__look_around__01` - `stand__arms_crossed_front__01__shuffle__01` - `stand__arms_crossed_front__01__shuffle__02` - `stand__arms_crossed_front__01__shuffle__05` - `stand__arms_crossed_front__01__shuffle__06` - `stand__arms_crossed_front__01__shuffle__07` - `stand__arms_crossed_front__01__shuffle__08` ## Arms crossed, relaxed (`armscrossed2`) Workspot: `base\workspots\common\ground\generic__stand_ground_arms_crossed__stand_around__03.workspot`. Open77 playback workspot: `cyberm\workspots\rp\armscrossed2.workspot`. Default clip: `stand__arms_crossed_front__02__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__arms_crossed_front__02__look_around__01` - `stand__arms_crossed_front__02__shuffle__01` - `stand__arms_crossed_front__02__shuffle__02` - `stand__arms_crossed_front__02__shuffle__03` - `stand__arms_crossed_front__02__shuffle__04` ## Hands behind back (`handsback`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__05.workspot`. Open77 playback workspot: `cyberm\workspots\rp\handsback.workspot`. Default clip: `stand__2h_back__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_back__01__look_around__01` - `stand__2h_back__01__rub_forehead__01` - `stand__2h_back__01__rub_nose__01` - `stand__2h_back__01__shuffle__01` - `stand__2h_back__01__shuffle__02` - `stand__2h_back__01__shuffle__03` ## Hands on hips (`handships`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__03.workspot`. Open77 playback workspot: `cyberm\workspots\rp\handships.workspot`. Default clip: `stand__2h_on_hip__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_hip__01__look_around__01` - `stand__2h_on_hip__01__shrug__02` - `stand__2h_on_hip__01__shuffle__01` - `stand__2h_on_hip__01__shuffle__04` - `stand__2h_on_hip__01__shuffle__05` - `stand__2h_on_hip__01__shuffle__06` - `stand__2h_on_hip__01__shuffle__07` - `stand__2h_on_hip__01__shuffle__08` - `stand__2h_on_hip__01__shuffle__09` ## Hands in front (`handsfront`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__04.workspot`. Open77 playback workspot: `cyberm\workspots\rp\handsfront.workspot`. Default clip: `stand__2h_front__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_front__01__check_shoes__01` - `stand__2h_front__01__dust_off__01` - `stand__2h_front__01__look_around__01` - `stand__2h_front__01__look_around__02` - `stand__2h_front__01__rub_nose__01` - `stand__2h_front__01__scratch_face__01` - `stand__2h_front__01__scratch_neck__01` - `stand__2h_front__01__shuffle__01` - `stand__2h_front__01__shuffle__02` - `stand__2h_front__01__shuffle__03` - `stand__2h_front__01__shuffle__04` - `stand__2h_front__01__shuffle__05` ## Hand on hip (`onehip`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__10.workspot`. Open77 playback workspot: `cyberm\workspots\rp\onehip.workspot`. Default clip: `stand__rh_on_hip__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_on_hip__01__shuffle__01` - `stand__rh_on_hip__01__shuffle__02` - `stand__rh_on_hip__01__shuffle__03` ## Casual standing (`idle`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\idle.workspot`. Default clip: `stand__2h_on_sides__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__cracking_knuckles__01` - `stand__2h_on_sides__01__scratch_neck__01` - `stand__2h_on_sides__01__shuffle__01` - `stand__2h_on_sides__01__shuffle__04` - `stand__2h_on_sides__01__sigh__01` - `stand__2h_on_sides__01__sigh__02` - `stand__2h_on_sides__01__sigh__03` ## Wait around (`wait`) Workspot: `base\workspots\common\ground\generic__stand_ground__wait__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\wait.workspot`. Default clip: `stand__2h_on_sides__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__look_around__01` - `stand__2h_on_sides__01__rub_hands__02` - `stand__2h_on_sides__01__rub_hands__03` - `stand__2h_on_sides__01__scratch_neck__03` - `stand__2h_on_sides__01__wait_look_right__01` - `stand__2h_on_sides__01__wait_lookaround__06` ## Bored (`bored`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__06.workspot`. Open77 playback workspot: `cyberm\workspots\rp\bored.workspot`. Default clip: `stand__2h_on_sides__02__slow_hours__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__02__slow_hours__01` - `stand__2h_on_sides__02__slow_hours__02` - `stand__2h_on_sides__02__slow_hours__03` - `stand__2h_on_sides__02__slow_hours__04` - `stand__2h_on_sides__02__slow_hours__05` ## Fidget with hands (`fidget`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__07.workspot`. Open77 playback workspot: `cyberm\workspots\rp\fidget.workspot`. Default clip: `stand__2h_on_sides__01__gesture_hold__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__gesture_hold__01__lh_fingers__01` - `stand__2h_on_sides__01__gesture_hold__01__lh_fingers__02` - `stand__2h_on_sides__01__gesture_hold__01__rh_fingers__01` - `stand__2h_on_sides__01__gesture_hold__01__rh_fingers__02` - `stand__2h_on_sides__01__gesture_hold__01__shuffle__01` ## Rub hands (`rubhands`) Workspot: `base\workspots\common\ground\generic__stand_ground__stand_around__09.workspot`. Open77 playback workspot: `cyberm\workspots\rp\rubhands.workspot`. Default clip: `stand__2h_on_sides__03__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__03__rub_hands__01` - `stand__2h_on_sides__03__shuffle__01` - `stand__2h_on_sides__03__shuffle__02` ## Feeling down (`sad`) Workspot: `base\workspots\common\ground\generic__stand_ground__wait_sad__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\sad.workspot`. Default clip: `stand__lh_crossed_front__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__lh_crossed_front__01__look_around__01` - `stand__lh_crossed_front__01__look_around__02` - `stand__lh_crossed_front__01__look_around__03` - `stand__lh_crossed_front__01__look_around__04` - `stand__lh_crossed_front__01__shuffle__01` - `stand__lh_crossed_front__01__shuffle__02` - `stand__lh_crossed_front__01__shuffle__04` ## Nervous (`nervous`) Workspot: `base\workspots\common\ground\generic__stand_ground__wait_nervous__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\nervous.workspot`. Default clip: `stand__2h_on_sides__01__deep_breath__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__deep_breath__01` - `stand__2h_on_sides__01__wait_station_nervous_04` ## Angry gestures (`angry`) Workspot: `base\workspots\common\ground\generic__stand_ground__angry__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\angry.workspot`. Default clip: `stand__2h_on_sides__01__fuck_you__angry__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__fuck_you__angry__01` - `stand__2h_on_sides__01__fuck_you__angry__02` - `stand__2h_on_sides__01__impatient__01` - `stand__2h_on_sides__01__impatient__02` - `stand__2h_on_sides__01__impatient__03` - `stand__2h_on_sides__01__shuffle__03` - `stand__2h_on_sides__01__shuffle__05` - `stand__2h_on_sides__01__this_or_that__angry__01` - `stand__2h_on_sides__01__threaten__angry__01` - `stand__2h_on_sides__01__threaten__angry__03` - `stand__2h_on_sides__01__what_is_this__angry__02` - `stand__2h_on_sides__shout__angry__01` ## Cheer (`cheer`) Workspot: `base\workspots\common\ground\generic__stand_ground__cheer__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\cheer.workspot`. Default clip: `stand__2h_on_sides__01__check_phone__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__check_phone__01` - `stand__2h_on_sides__01__demonstrate_positive__01` - `stand__2h_on_sides__01__demonstrate_positive__02` - `stand__2h_on_sides__01__demonstrate_positive__04` - `stand__2h_on_sides__01__demonstrate_positive__09` - `stand__2h_on_sides__01__scratch_neck__04` - `stand__2h_on_sides__01__shuffle__02` ## Cheer, folded arms (`cheer2`) Workspot: `base\workspots\common\ground\generic__stand_ground__cheer__02.workspot`. Open77 playback workspot: `cyberm\workspots\rp\cheer2.workspot`. Default clip: `stand__arms_crossed_front__01__2h_clap__happy__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__arms_crossed_front__01__2h_clap__happy__01` - `stand__arms_crossed_front__01__cheer__happy__01` - `stand__arms_crossed_front__01__go_away__happy__01` - `stand__arms_crossed_front__01__rub_eyes__01` - `stand__arms_crossed_front__01__thumb_up__happy__01` - `stand__arms_crossed_front__01__thumb_up__neutral__01` - `stand__arms_crossed_front__01__yes__happy__01` ## Crowd wave (`crowdwave`) Workspot: `base\workspots\common\ground\generic__stand_ground__crowd_wave__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\crowdwave.workspot`. Default clip: `stand__2h_on_sides__01__crowd_wave__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__crowd_wave__01` - `stand__2h_on_sides__01__crowd_wave__02` ## Speak to a crowd (`preach`) Workspot: `base\workspots\common\ground\generic__stand_ground__preach__02.workspot`. Open77 playback workspot: `cyberm\workspots\rp\preach.workspot`. Default clip: `stand__preach__01__breathe__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__preach__01__breathe__01` - `stand__preach__01__look_around__01` - `stand__preach__01__look_right__01` - `stand__preach__01__preach__01` - `stand__preach__01__preach__02` - `stand__preach__01__preach__03` - `stand__preach__01__preach__04` - `stand__preach__01__shuffle__01` ## Calm down (`calmdown`) Workspot: `base\workspots\common\ground\generic__stand_ground__preach__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\calmdown.workspot`. Default clip: `stand__2h_on_sides__01__calm_down__disgusted__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__calm_down__disgusted__01` - `stand__2h_on_sides__01__talk__neutral__05` ## Order food (`order`) Workspot: `base\workspots\common\ground\generic__stand_ground__order__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\order.workspot`. Default clip: `stand__2h_on_sides__01__order_food__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__order_food__01` - `stand__2h_on_sides__01__order_food__02` - `stand__2h_on_sides__01__order_food__03` ## Drunk (`drunk`) Workspot: `base\workspots\common\ground\generic__stand_ground__drunk__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\drunk.workspot`. Default clip: `stand__drunk__01__check_pockets__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__drunk__01__check_pockets__01` - `stand__drunk__01__gag_reflex__01` - `stand__drunk__01__look_back__01` - `stand__drunk__01__rub_face__01` - `stand__drunk__01__scratch__01` - `stand__drunk__01__scratch_ball__01` ## Paranoid (`paranoid`) Workspot: `base\workspots\common\ground\generic__stand_ground__paranoid__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\paranoid.workspot`. Default clip: `stand__paranoid__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__paranoid__01__2h_head__01` - `stand__paranoid__01__2h_wave__01` - `stand__paranoid__01__chase_away_gesture__01` - `stand__paranoid__01__fist_wave__01` - `stand__paranoid__01__headache__01` - `stand__paranoid__01__look_around__01` - `stand__paranoid__01__look_down__01` - `stand__paranoid__01__look_left__01` - `stand__paranoid__01__look_right__01` - `stand__paranoid__01__panic__01` - `stand__paranoid__01__point_front__01` - `stand__paranoid__01__rh_rubs_eye__01` - `stand__paranoid__01__scratches_arm__01` - `stand__paranoid__01__shoulders_shrug__1` ## Tai chi (`taichi`) Workspot: `base\workspots\common\ground\generic__stand_ground__tai_chi__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\taichi.workspot`. Default clip: `stand__tai_chi__01__breathe__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__tai_chi__01__breathe__01` - `stand__tai_chi__01__tai_chi_front__01` ## Dance to headphones (`headphones`) Workspot: `base\workspots\common\ground\generic__stand_ground_headphones__dance__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\headphones.workspot`. Default clip: `stand__2h_on_sides__01__wait_dance_headphones_01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_on_sides__01__wait_dance_headphones_01` - `stand__2h_on_sides__01__wait_dance_headphones_02` ## Phone call (`call`) Workspot: `base\workspots\common\ground\generic__stand_ground__phone_talk__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\call.workspot`. Default clip: `stand__rh_phone__03__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_phone__03__look_up__01` - `stand__rh_phone__03__no__01` - `stand__rh_phone__03__nod__01` - `stand__rh_phone__03__nod__02` - `stand__rh_phone__03__nod__03` - `stand__rh_phone__03__shuffle__01` - `stand__rh_phone__03__shuffle__02` - `stand__rh_phone__03__talk__01` - `stand__rh_phone__03__talk__02` - `stand__rh_phone__03__talk__03` ## Phone call, hand on hip (`call2`) Workspot: `base\workspots\common\ground\generic__stand_ground__phone_talk__03.workspot`. Open77 playback workspot: `cyberm\workspots\rp\call2.workspot`. Default clip: `stand__lh_hip_rh_phone__01__talk_phone__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__lh_hip_rh_phone__01__talk_phone__01` - `stand__lh_hip_rh_phone__01__talk_phone__02` ## Check phone (`phonecheck`) Workspot: `base\workspots\common\ground\generic__stand_ground_cellphone__check_phone__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\phonecheck.workspot`. Default clip: `stand__2h_phone__03__play_games__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_phone__03__play_games__01` - `stand__2h_phone__03__play_games__02` - `stand__2h_phone__03__play_games__03` - `stand__2h_phone__03__play_games__04` - `stand__2h_phone__03__scratch_nose__01` ## Record on phone (`record`) Workspot: `base\workspots\common\ground\generic__stand_ground_cellphone__record__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\record.workspot`. Default clip: `stand__2h_phone__02__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_phone__02__adjust_phone__01` - `stand__2h_phone__02__look__01` - `stand__2h_phone__02__look_around__01` - `stand__2h_phone__02__rub_face__01` - `stand__2h_phone__02__shuffle__01` - `stand__2h_phone__02__step_forward__01` ## Drink from a bottle (`bottle`) Workspot: `base\workspots\common\ground\generic__stand_bottle__drink__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\bottle.workspot`. Default clip: `stand__rh_bottle__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_bottle__01__drink__01` - `stand__rh_bottle__01__drink__02` - `stand__rh_bottle__01__drink__03` - `stand__rh_bottle__01__scratch__01` - `stand__rh_bottle__01__shuffle__01` - `stand__rh_bottle__01__shuffle__02` - `stand__rh_bottle__01__shuffle__03` - `stand__rh_bottle__01__shuffle__04` ## Eat takeout (`takeout`) Workspot: `base\workspots\common\ground\generic__stand_ground_asian_takeout__eat__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\takeout.workspot`. Default clip: `stand__2h_chinesetakeout__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_chinesetakeout__01__eat__01` - `stand__2h_chinesetakeout__01__eat__02` - `stand__2h_chinesetakeout__01__eat__03` - `stand__2h_chinesetakeout__01__shuffle__01` - `stand__2h_chinesetakeout__01__shuffle__02` - `stand__2h_chinesetakeout__01__shuffle__03` ## Smoke, arm folded (`smokefolded`) Workspot: `base\workspots\common\ground\generic__stand_ground_cigarette__smoke__04.workspot`. Open77 playback workspot: `cyberm\workspots\rp\smokefolded.workspot`. Default clip: `stand__rh_cigarette_lh_crossed__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__rh_cigarette_lh_crossed__01__look_at_rh__01` - `stand__rh_cigarette_lh_crossed__01__shuffle__01` - `stand__rh_cigarette_lh_crossed__01__smoke__01` - `stand__rh_cigarette_lh_crossed__01__smoke__02` - `stand__rh_cigarette_lh_crossed__01__smoke__03` - `stand__rh_cigarette_lh_crossed__01__smoke__04` - `stand__rh_cigarette_lh_crossed__01__smoke_long__01` ## Use a tablet (`tablet`) Workspot: `base\workspots\common\ground\generic__stand_ground_tablet__stand_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\tablet.workspot`. Default clip: `stand__2h_tablet__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_tablet__01__look_around__01` - `stand__2h_tablet__01__look_left__01` - `stand__2h_tablet__01__look_right__01` - `stand__2h_tablet__01__rub_neck__01` - `stand__2h_tablet__01__shuffle__02` - `stand__2h_tablet__01__shuffle__03` - `stand__2h_tablet__01__shuffle__04` - `stand__2h_tablet__01__shuffle__05` - `stand__2h_tablet__01__shuffle__06` - `stand__2h_tablet__01__use_tablet__01` - `stand__2h_tablet__01__use_tablet__02` - `stand__2h_tablet__01__use_tablet__04` ## Work on tablet (`tablet2`) Workspot: `base\workspots\common\ground\generic__stand_ground_tablet__use__02.workspot`. Open77 playback workspot: `cyberm\workspots\rp\tablet2.workspot`. Default clip: `stand__2h_tablet__02__use_tablet__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__2h_tablet__02__use_tablet__01` - `stand__2h_tablet__02__use_tablet__02` - `stand__2h_tablet__02__use_tablet__03` - `stand__2h_tablet__02__use_tablet__04` - `stand__2h_tablet__02__use_tablet__05` - `stand__2h_tablet__02__use_tablet__06` ## Tablet in one hand (`tablet3`) Workspot: `base\workspots\common\ground\generic__stand_ground_tablet__use__03.workspot`. Open77 playback workspot: `cyberm\workspots\rp\tablet3.workspot`. Default clip: `stand__lh_tablet__01__slow_hours__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand__lh_tablet__01__slow_hours__01` - `stand__lh_tablet__01__slow_hours__02` - `stand__lh_tablet__01__slow_hours__03` - `stand__lh_tablet__01__use_tablet__01` - `stand__lh_tablet__01__use_tablet__02` ## Kneel and repair (`repair`) Workspot: `base\workspots\common\ground\generic__kneel_ground_lean_front_screwdriver__repair__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\repair.workspot`. Default clip: `kneel__rh_screwdriver__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `kneel__rh_screwdriver__01__look_back__01` - `kneel__rh_screwdriver__01__repair_01` - `kneel__rh_screwdriver__01__repair_02` - `kneel__rh_screwdriver__01__repair_03` - `kneel__rh_screwdriver__01__scratch_neck__01` - `kneel__rh_screwdriver__01__shuffle__01` - `kneel__rh_screwdriver__01__shuffle__02` ## Kneel with a tool (`mechanic`) Workspot: `base\workspots\common\ground\generic__kneel_ground_tool__fix__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\mechanic.workspot`. Default clip: `kneel__rh_tool_fix__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `kneel__rh_tool_fix__01__gesture__01` - `kneel__rh_tool_fix__01__gesture__02` - `kneel__rh_tool_fix__01__long__01` - `kneel__rh_tool_fix__01__shuffle__01` - `kneel__rh_tool_fix__01__shuffle__02` - `kneel__rh_tool_fix__tool_stuck__01` ## Scavenge (`scavenge`) Workspot: `base\workspots\common\ground\generic__kneel_ground__scavenge__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\scavenge.workspot`. Default clip: `kneel__2h_on_lap__01__scavenge_part_drone_01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `kneel__2h_on_lap__01__scavenge_part_drone_01` - `kneel__2h_on_lap__01__scavenge_part_drone_02` - `kneel__2h_on_lap__01__scavenge_part_drone_03` ## Cry on knees (`kneelcry`) Workspot: `base\workspots\common\ground\generic__kneel_ground__cry__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\kneelcry.workspot`. Default clip: `kneel_lean0__cry__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `kneel_lean0__cry__01` ## Cower (`cower`) Workspot: `base\workspots\common\ground\generic__kneel_ground_lean_front__afraid__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\cower.workspot`. Default clip: `kneel_lean0__lh_on_head__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `kneel_lean0__lh_on_head__01__cover_ears__01` - `kneel_lean0__lh_on_head__01__lie_down__01` - `kneel_lean0__lh_on_head__01__look_around__01` - `kneel_lean0__lh_on_head__01__scared__01` - `kneel_lean0__lh_on_head__01__shuffle__01` - `kneel_lean0__lh_on_head__01__take_cover__01` ## Lie face down (`arrested`) Workspot: `base\workspots\common\ground\generic__lie_ground_lean_front__arrested__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\arrested.workspot`. Default clip: `lie_ground_0__2h_on_head__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `lie_ground_0__2h_on_head__01__shuffle__01` - `lie_ground_0__2h_on_head__01__shuffle__03` - `lie_ground_0__2h_on_head__01__shuffle__06` ## Sit with knees held (`sitafraid`) Workspot: `base\workspots\common\ground\generic__sit_ground_lean_front__scared__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\sitafraid.workspot`. Default clip: `sit_ground_lean0__2h_around_legs__01__scared_shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_ground_lean0__2h_around_legs__01__long__01` - `sit_ground_lean0__2h_around_legs__01__react__01` - `sit_ground_lean0__2h_around_legs__01__react__02` - `sit_ground_lean0__2h_around_legs__01__scared_shuffle__01` - `sit_ground_lean0__2h_around_legs__01__scared_shuffle__02` ## Smoke on the ground (`sitsmoke`) Workspot: `base\workspots\common\ground\generic__sit_ground_cigarette__smoke__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\sitsmoke.workspot`. Default clip: `sit_ground__rh_cigarette__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_ground__rh_cigarette__01__shuffle__01` - `sit_ground__rh_cigarette__01__shuffle__02` - `sit_ground__rh_cigarette__01__smoke__01` - `sit_ground__rh_cigarette__01__smoke__02` - `sit_ground__rh_cigarette__01__smoke__03` ## Use laptop on ground (`laptop`) Workspot: `base\workspots\common\ground\generic__sit_ground_laptop__use__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\laptop.workspot`. Default clip: `sit_ground_lean0__2h_on_laptop__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_ground_lean0__2h_on_laptop__01__head_flick_up__01` - `sit_ground_lean0__2h_on_laptop__01__look_left__01` - `sit_ground_lean0__2h_on_laptop__01__look_left__02` - `sit_ground_lean0__2h_on_laptop__01__look_right__01` - `sit_ground_lean0__2h_on_laptop__01__look_up__01` - `sit_ground_lean0__2h_on_laptop__01__shuffle__01` - `sit_ground_lean0__2h_on_laptop__01__type__01` - `sit_ground_lean0__2h_on_laptop__01__type__02` - `sit_ground_lean0__2h_on_laptop__01__type__03` ## Play a handpan (`handpan`) Workspot: `base\workspots\common\ground\generic__sit_ground_handpan__play__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\handpan.workspot`. Default clip: `sit_ground__play_handpan__play__01__phrase__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_ground__play_handpan__play__01__phrase__01` - `sit_ground__play_handpan__play__01__phrase__02` - `sit_ground__play_handpan__play__01__phrase__03` - `sit_ground__play_handpan__play__01__phrase__04` - `sit_ground__play_handpan__play__01__phrase__05` - `sit_ground__play_handpan__play__01__phrase__06` - `sit_ground__play_handpan__play__01__phrase__07` - `sit_ground__play_handpan__play__01__phrase__08` - `sit_ground__play_handpan__play__01__phrase__10` ## Lean against wall, relaxed (`leanrelaxed`) Workspot: `base\workspots\common\wall\generic__stand_wall_lean_back__stand_around__02.workspot`. Open77 playback workspot: `cyberm\workspots\rp\leanrelaxed.workspot`. Default clip: `stand_wall_lean180__2h_on_sides__01__clean_clothes__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand_wall_lean180__2h_on_sides__01__clean_clothes__01` - `stand_wall_lean180__2h_on_sides__01__clean_clothes__02` - `stand_wall_lean180__2h_on_sides__01__clean_clothes__03` - `stand_wall_lean180__2h_on_sides__01__rub_neck__01` - `stand_wall_lean180__2h_on_sides__01__sigh_heavy__01` - `stand_wall_lean180__2h_on_sides__01__sigh_heavy__02` - `stand_wall_lean180__2h_on_sides__01__stretch_shoulders__01` ## Lean on left side (`leanleft`) Workspot: `base\workspots\common\wall\generic__stand_wall_lean_left__stand_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\leanleft.workspot`. Default clip: `stand_wall_lean270__rh_on_hip__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand_wall_lean270__rh_on_hip__01__shuffle__01` - `stand_wall_lean270__rh_on_hip__01__shuffle__02` - `stand_wall_lean270__rh_on_hip__01__tap_wall__01` ## Phone call against wall (`wallcall`) Workspot: `base\workspots\common\wall\generic__stand_wall_lean_back_cellphone__talk__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\wallcall.workspot`. Default clip: `stand_wall_lean180__rh_phone__01__talk_phone__02`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand_wall_lean180__rh_phone__01__talk_phone__02` - `stand_wall_lean180__rh_phone__01__talk_phone__03` ## Smoke against wall (`wallsmoke`) Workspot: `base\workspots\common\wall\generic__stand_wall_lean_back_cigarette__smoke__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\wallsmoke.workspot`. Default clip: `stand_wall_lean180__rh_cigarette__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand_wall_lean180__rh_cigarette__01__drop_ash__01` - `stand_wall_lean180__rh_cigarette__01__puff__01` - `stand_wall_lean180__rh_cigarette__01__shuffle__01` - `stand_wall_lean180__rh_cigarette__01__shuffle__02` ## Sit upright (`seated`) Workspot: `base\workspots\common\chair\generic__sit_chair__sit_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\seated.workspot`. Default clip: `sit_chair__2h_on_lap__01__look_around__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair__2h_on_lap__01__deep_breath__01` - `sit_chair__2h_on_lap__01__look_around__01` - `sit_chair__2h_on_lap__01__shuffle__01` - `sit_chair__2h_on_lap__01__shuffle__02` - `sit_chair__2h_on_lap__01__shuffle__03` - `sit_chair__2h_on_lap__01__shuffle__04` - `sit_chair__2h_on_lap__01__sigh__01` ## Sit, relaxed hands (`seated2`) Workspot: `base\workspots\common\chair\generic__sit_chair__sit_around__02.workspot`. Open77 playback workspot: `cyberm\workspots\rp\seated2.workspot`. Default clip: `sit_chair__2h_on_lap__02__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair__2h_on_lap__02__shuffle__01` - `sit_chair__2h_on_lap__02__shuffle__02` - `sit_chair__2h_on_lap__02__shuffle__03` - `sit_chair__2h_on_lap__02__shuffle__04` - `sit_chair__2h_on_lap__02__shuffle__05` ## Cheer while seated (`seatedcheer`) Workspot: `base\workspots\common\chair\generic__sit_chair__cheer__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\seatedcheer.workspot`. Default clip: `sit_chair__2h_on_lap__01__2h_clap__happy__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair__2h_on_lap__01__2h_clap__happy__01` - `sit_chair__2h_on_lap__01__2h_clap__happy__02` - `sit_chair__2h_on_lap__01__cheer__happy__01` - `sit_chair__2h_on_lap__01__describe_front__02` - `sit_chair__2h_on_lap__01__shuffle__06` - `sit_chair__2h_on_lap__01__what_is_this__angry__01` - `sit_chair__2h_on_lap__01__what_is_this__neutral__02` - `sit_chair__2h_on_lap__01__yawn__01` ## Doze in a chair (`seatedsleep`) Workspot: `base\workspots\common\chair\generic__sit_chair__sleep__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\seatedsleep.workspot`. Default clip: `sit_chair__2h_on_lap__01__rub_eyes__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair__2h_on_lap__01__rub_eyes__01` - `sit_chair__2h_on_lap__01__sleep__long__01` - `sit_chair__2h_on_lap__01__sleep_bus__01` ## Stretch while seated (`seatedstretch`) Workspot: `base\workspots\common\chair\generic__sit_chair__stretch__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\seatedstretch.workspot`. Default clip: `sit_chair__2h_on_lap__01__stretch_muscle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair__2h_on_lap__01__stretch_muscle__01` - `sit_chair__2h_on_lap__01__stretch_muscle__02` - `sit_chair__2h_on_lap__01__stretch_muscle__03` - `sit_chair__2h_on_lap__01__stretch_muscle__04` - `sit_chair__2h_on_lap__01__stretch_muscle__05` - `sit_chair__2h_on_lap__01__stretch_muscle__06` ## Think while seated (`seatedthink`) Workspot: `base\workspots\common\chair\generic__sit_chair_lean_front__think__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\seatedthink.workspot`. Default clip: `sit_chair_lean0__rh_on_chin__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair_lean0__rh_on_chin__01__consider__01` - `sit_chair_lean0__rh_on_chin__01__head_flick_up__01` - `sit_chair_lean0__rh_on_chin__01__long__01` - `sit_chair_lean0__rh_on_chin__01__nod__01` - `sit_chair_lean0__rh_on_chin__01__shuffle__01` - `sit_chair_lean0__rh_on_chin__01__shuffle__02` ## Play guitar (`guitar`) Workspot: `base\workspots\common\chair\generic__sit_chair_lean_front_guitar__play__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\guitar.workspot`. Default clip: `sit_chair_lean0__2h_guitar__no_pick_fingers__01__play__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_chair_lean0__2h_guitar__no_pick_fingers__01__play__01` - `sit_chair_lean0__2h_guitar__no_pick_ponpon__01__play__01` - `sit_chair_lean0__2h_guitar__no_pick_ponpon__01__play__02` - `sit_chair_lean0__2h_guitar__pick_beast__01__play__01` - `sit_chair_lean0__2h_guitar__pick_beast__01__play__02` - `sit_chair_lean0__2h_guitar__pick_chippin__01__play__01` ## Lean on a counter (`barlean`) Workspot: `base\workspots\common\bar\generic__stand_bar_lean_front__stand_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\barlean.workspot`. Default clip: `stand_bar_lean0__2h_on_bar__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `stand_bar_lean0__2h_on_bar__01__clean_desk_l_side__01` - `stand_bar_lean0__2h_on_bar__01__clean_desk_l_side__02` - `stand_bar_lean0__2h_on_bar__01__clean_desk_r_side__01` - `stand_bar_lean0__2h_on_bar__01__look_l_r__01` - `stand_bar_lean0__2h_on_bar__01__shuffle__01` - `stand_bar_lean0__2h_on_bar__01__shuffle__02` - `stand_bar_lean0__2h_on_bar__01__shuffle__03` - `stand_bar_lean0__2h_on_bar__01__shuffle__04` - `stand_bar_lean0__2h_on_bar__01__shuffle__05` - `stand_bar_lean0__2h_on_bar__01__shuffle__06` - `stand_bar_lean0__2h_on_bar__01__stretch_arms__01` - `stand_bar_lean0__2h_on_bar__01__tapping__01` - `stand_bar_lean0__2h_on_bar__01__tapping__02` - `stand_bar_lean0__2h_on_bar__01__yawn__01` ## Sit at a bar (`barsit`) Workspot: `base\workspots\common\bar\generic__sit_bar__sit_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\barsit.workspot`. Default clip: `sit_bar__2h_on_bar__01__breathe__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `sit_bar__2h_on_bar__01__breathe__01` - `sit_bar__2h_on_bar__01__lean_forward__01` - `sit_bar__2h_on_bar__01__long__01` - `sit_bar__2h_on_bar__01__shuffle__01` - `sit_bar__2h_on_bar__01__tap_on_bar__01` ## Lie on side (`bedsidelie`) Workspot: `base\workspots\common\bed\generic__lie_bed_lean_left__lie_around__01.workspot`. Open77 playback workspot: `cyberm\workspots\rp\bedsidelie.workspot`. Default clip: `lie_bed_270__lh_on_head__01__shuffle__01`. Rig bindings: male and female base rigs. Experimental presentation. Selectable authored clips: - `lie_bed_270__lh_on_head__01__shuffle__01` ## Discovery inventory The complete extracted common-workspot inventory is [rp-workspots.json](../docs/data/rp-workspots.json). It records workspot hashes, source animsets, rigs and animation-node roles. It is not a playback allowlist. The older [raw clip list](../docs/data/emote-animations.txt) also contains quest and special-rig clips. --- Source: https://open2077.net/docs/player-interactions # Player interactions `Open77.playerInteractions` coordinates two-player actions with participant reservations, consent, presentation readiness, a shared start and cleanup. Gameplay effects such as item transfer, healing or payment remain the resource's responsibility. An accepted interaction confirms server admission, not successful native animation playback. Handle presentation failure and cancellation events. ## Setup The server must load [`open77_player_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_player_interactions), [`open77_animations`](https://github.com/Open2077/open77-resources/tree/main/system/open77_animations) and the matching client animation/proxy archives. The default server configuration loads the new system resource. Existing custom configurations must add it explicitly. ```lua resource 'medical_actions' version '1.0.0' dependency 'open77_player_interactions >=1.0.0' permissions { 'players.interactions.read', 'players.interactions.control', 'world.props', -- only if this resource also manages attached props } server_script 'server.lua' client_script 'client.lua' ``` `read` permits inspection; `control` permits server creation and cancellation of owned interactions. Client resources can inspect, accept/decline their own invitation or cancel an action they participate in. A client cannot create an authoritative pair or accept for somebody else. Use your normal authenticated server event or command for gameplay requests. ## Lifecycle ```text request → offered → preparing → scheduled → active → completed ↓ ↓ ↓ ↓ cancelled ``` Both players are reserved immediately, including during the invitation. Only the target can accept. `consent=false` skips the invitation, not validation or readiness. Use it only for gameplay whose authorization is already established. Both clients acknowledge preparation before the server schedules a common start 500 ms ahead. Preparation times out after eight seconds. Observers reconcile the same state; snapshot repair handles late joins and missed terminal updates. This is scheduled gameplay coordination, not a frame-perfect cinematic timeline. Native `give`/`heal` workspots already play their entry while preparing and are restarted at the shared start; readiness is not an engine-side paused preload. Streaming, frame rate and animation entry/loop timing can differ between clients. Do not time an inventory transaction from a hand pose or a local animation event. `completed` is emitted once by the server coordinator. Terminal interactions leave the active registry; keep terminal information in your resource if needed. Client events are visual notifications, **never authority to grant a reward**. The system reserves only this interaction/animation service. Your own inventory, health, vehicle, job and economy systems must still check their business rules. ## Request options | Field | Default and constraints | | --- | --- | | `durationMs` | `4000`; integer 500–600000. Starts at the shared scheduled time, not at invitation. | | `startDistance` | `3` metres; finite 0.25–10. Checked against server-observed positions. | | `breakDistance` | `5` metres; finite, at least `startDistance`, at most 20. | | `inviteTimeoutMs` | `15000`; integer 1000–60000. | | `consent` | `true`; boolean. | | `actorAnimation` | Optional RP profile ID for `give`, `heal` or `custom`. | | `targetAnimation` | Optional RP profile ID for `give`, `heal` or `custom`. | Unknown fields are rejected. Profile IDs must exist in the RP catalogue. `carry`/`escort` use their fixed paired presentation and reject profile overrides with `paired_animation_fixed`. Prop local offsets/rotations belong to the [attachment API](attachments.md), not to these options. Kinds: | Kind | Presentation and gameplay responsibility | | --- | --- | | `give` | Item-offering profiles on both bodies; your server code commits the item transfer. | | `heal` | Examination and wounded/resting profiles; your server code applies healing. | | `carry` | Carried-body pose plus carrier arm layers; actor can move and target follows. No damage or dead-ragdoll pickup. | | `escort` | Side-by-side guided movement with walking poses. No vanilla takedown or damage is started. | | `custom` | Optional specified RP profiles under the same reservation/consent protocol. | Participants must be incarnated, alive, on foot, nearby and in the same bucket. An injured/downed player may be represented by your gamemode while still alive; this API does not attach a dead ragdoll or override engine death. Moving away from the starting spot cancels stationary `give`/`heal`/`custom` actions; a carried pair can move together but separation still cancels it. Death, disconnect, bucket change, entering a vehicle, resource stop, timeout, rejection and native presentation failure release reservations. Local presentation temporarily uses the same F7 body as observers without changing the saved camera preference. For carry/escort, only the visual stand-in is mounted to the rendered parent; the real target retains its engine movement controller and follows through the guarded native travel facility. Input restrictions and mutual proxy-collision suppression are scoped to the interaction. Release searches nearby walkable ground; if no safe drop exists, participants can separate before their mutual collision is restored. Existing gamemode ghost/input policies are preserved. These are presentation building blocks, not hand IK, obstacle pathfinding or an authoritative collision solver. Your resource must validate the action's location and gameplay rules (including line of sight when required). `give` and `heal` turn each local participant toward the other before starting the workspot, without changing their positions. Choose a suitable distance for the selected profiles; facing alone cannot calibrate a grip between two bodies. ## Server API Calls return directly, not promises. Failure is `nil, reason`. | Method | Success result | | --- | --- | | `request(actorId, targetId, kind, options?)` | State table. The calling resource owns this interaction. | | `get(interactionId)` | Active state, or `nil` when absent. | | `current(playerId)` | The player's reserved interaction, or `nil`. | | `isReserved(playerId)` | Boolean. | | `list()` | Active interactions owned by this resource. | | `cancel(interactionId, reason?)` | Terminal state. Cannot cancel another resource's action. | These methods are under `Open77.playerInteractions`. Cancellation reasons accept 1–64 ASCII letters/digits/underscores; invalid custom reasons become `cancelled`. Interaction IDs are opaque 32-character hexadecimal strings. Never reuse one as a player ID or native entity handle. State fields: `id`, `kind`, decimal-string `actor`/`target`, `phase`, `reason`, `bucket`, `options`, `epoch`, `revision`, `serverTimeMs`, `startsAtMs`, `endsAtMs`. Times are server monotonic milliseconds, **not Unix timestamps**. Start/end are zero before scheduling. Epoch/revision make old messages distinguishable; clients must not manufacture or edit authoritative state. Server events, delivered to the owning resource: ```lua AddEventHandler('onPlayerInteractionChanged', function(state) end) AddEventHandler('onPlayerInteractionStarted', function(state) end) AddEventHandler('onPlayerInteractionCompleted', function(state) end) AddEventHandler('onPlayerInteractionCancelled', function(state) end) ``` ## Give an attached item This example assumes that the same server resource owns the prop and its gameplay ownership table. For separate inventory resources, call their server exports; do not try to mutate a foreign prop directly. ```lua local pending = {} local function giveItem(actor, target, propId) -- Validate inventory ownership, permissions and item availability here. local prop = Open77.props.get(propId) if not prop or not prop.attachment or prop.attachment.parentType ~= 'player' or tonumber(prop.attachment.parentId) ~= actor then return nil, 'not_carried' end local state, err = Open77.playerInteractions.request(actor, target, 'give') if not state then return nil, err end pending[state.id] = {propId=propId, revision=prop.revision} return state end AddEventHandler('onPlayerInteractionCompleted', function(state) local transfer = pending[state.id] pending[state.id] = nil -- consume before calling other code if not transfer then return end -- Revalidate your inventory transaction here, before changing either owner. local ok, err = Open77.props.attach(transfer.propId, { parentType='player', parentId=state.target, bone='RightHand', }, transfer.revision) if not ok then print('Handoff refused: '..tostring(err)); return end -- Commit your resource's authoritative item owner after successful CAS. end) AddEventHandler('onPlayerInteractionCancelled', function(state) pending[state.id] = nil -- no item transfer on decline/death/disconnect end) ``` Do not add a timer that blindly grants the item after four seconds: the action may have been declined, cancelled, or never prepared. If your final transaction needs persistence, implement its own idempotency key and database transaction; the in-memory coordinator is not a durable inventory transaction log. ## Medical and guided-movement examples Server-side medical action (also declare `players.stats.read` and `players.stats.apply`). Replace the commented checks with your resource's job, item and line-of-sight rules before exposing this to players: ```lua local treatments = {} local function examine(medic, patient) -- Validate medic permissions and reserve the required medical item first. local state, err = Open77.playerInteractions.request(medic, patient, 'heal', { durationMs=8000, startDistance=2, breakDistance=3, }) if not state then return nil, err end treatments[state.id] = true return state end AddEventHandler('onPlayerInteractionCompleted', function(state) if not treatments[state.id] then return end treatments[state.id] = nil -- Revalidate/consume the reserved medical item before granting health. local patient = tonumber(state.target) local health = Open77.stats.getHealth(patient) if not health then return end local ok, err = Open77.stats.setHealth(patient, math.min(health.maximum, health.value + 25)) if not ok then print('Treatment refused: '..tostring(err)) end end) AddEventHandler('onPlayerInteractionCancelled', function(state) treatments[state.id] = nil -- release your medical-item reservation too end) ``` Carry/escort use the same invitation and cancellation UI. The caller must select an eligible, alive target; a gamemode's injured state is separate from engine death. The actor can walk, but neither participant can attack or jump during the action. The target cannot walk independently until release. ```lua -- Server, after checking your gameplay authorization: local state, err = Open77.playerInteractions.request(helperId, injuredId, 'carry', { durationMs=60000, consent=true, }) -- Use kind='escort' for side-by-side walking under the same lifecycle. -- Stop before the duration expires (only the owning resource can do this): -- Open77.playerInteractions.cancel(state.id, 'destination_reached') ``` Cancellation is a release, not successful completion. If reaching a destination should complete a job, validate and commit that job in your own server code; do not reinterpret every participant cancellation as a reward. ## Client API Client facade calls return an **Open77.Promise** routed to the bundled resource. Call `:await()` from a `CreateThread` coroutine. Distinguish a dispatch/rejection error from an absent state. Methods under `Open77.playerInteractions`: | Method | Promise resolves to | | --- | --- | | `list()` | Locally synchronized states in the current bucket. | | `get(id)` | State or nil. | | `current(playerId?)` | State for that player; defaults to the local player. | | `isReserved(playerId?)` | Boolean. | | `respond(id, accepted)` | Request ID, or `nil, reason`; target only, boolean response. | | `accept(id)` / `decline(id)` | Aliases for `respond`. | | `cancel(id?)` | Request ID; defaults to your current interaction. Participants only. | | `result(requestId)` | Cached server result, or nil while pending/expired. Kept 30 seconds. | A request ID means the message was submitted, **not** that the server accepted it. Observe `onPlayerInteractionResult` for the authoritative reply. The bundled chat command `/interaction accept`, `/interaction decline`, `/interaction cancel` lets a player respond even without a custom UI. ```lua AddEventHandler('onPlayerInteractionOffered', function(state) -- Show your UI here. Accept only after the player's explicit choice. end) local function acceptFromUi(id) CreateThread(function() local promise, err = Open77.playerInteractions.accept(id) if not promise then print(err); return end local requestId, rejected = promise:await() if not requestId then print(rejected); return end print('Waiting for server response: '..requestId) end) end AddEventHandler('onPlayerInteractionResult', function(result) -- result.requestId correlates the response with your UI action. if not result.ok then print(result.error) end end) ``` Clients also receive `onPlayerInteractionChanged`, `Started`, `Completed`, `Cancelled`, and `onPlayerInteractionPresentationFailed(id, reason)`. The latter reports a local native failure; the server remains responsible for releasing the pair. Never rebroadcast these events as authority. The `open77:playerInteractions:` network namespace and `_present` native are reserved for the system adapter. ## Failure handling and limits Common refusals include `invalid_participants`, `player_not_ready`, `player_not_alive`, `player_in_vehicle`, `position_unavailable`, `wrong_bucket`, `too_far`, `player_reserved`, `animation_busy`, `invalid_kind`, `invalid_duration`, `invalid_distance`, `unknown_animation`, `paired_animation_fixed`, `owned_by_another_resource`, `not_invited`, `not_participant`, `invalid_phase` and `stale_preparation`. Preserve reasons in developer logs; show a short, appropriate message to players. The coordinator permits at most 256 interactions globally and 64 per resource. Player replies are rate-limited and bound to the authenticated connection. Resource reload cancels its outstanding reservations. Snapshot repair does not replay completed gameplay callbacks or restore transactions after server restart. --- Source: https://open2077.net/docs/attachments # Synchronized attachments Attach a server-owned prop to a player or vehicle, optionally at a skeletal slot. Clients follow their rendered parent and animated slot; the server replicates the attachment binding and its revisions. Use [player interactions](player-interactions.md) to coordinate two players. Attaching a prop does not reserve a player or start an animation. ## Resource setup ```lua resource 'medical_bag' version '1.0.0' dependency 'open77_props >=0.1.0' permissions { 'world.props' } server_script 'server.lua' ``` The bundled [`open77_props`](https://github.com/Open2077/open77-resources/tree/main/system/open77_props) client projection must be running for synchronized props. The server owns the object and attachment. Another resource may inspect it, but cannot mutate or remove it. Resource stop removes its props. ## Attach a prop to a hand ```lua -- Server. playerId is authenticated/validated by your gameplay code. local p = Open77.players.position(playerId) if not p then return end local propId, reason = Open77.props.create({ model = 'container.gas_can', position = {x=p.x+2, y=p.y, z=p.z}, bucket = p.bucket, scale = 0.5, }) if not propId then print(reason); return end local ok, err = Open77.props.attach(propId, { parentType = 'player', parentId = playerId, bone = 'RightHand', offset = {x=0, y=0, z=0}, rotation = {x=0, y=0, z=0}, }) if not ok then Open77.props.remove(propId); print(err) end ``` The zero transform is a starting point, not a calibrated grip for every mesh. Tune the transform for your chosen model. For a back accessory, select a slot such as `Chest`/`spine` that the target rig actually exposes and adjust the offset. Never assume that every named slot exists on both the real player and a replica. ## Binding fields | Field | Meaning | | --- | --- | | `parentType` | Required: `"player"` or `"vehicle"`. | | `parentId` | Required canonical network ID, positive integer ≤ 2^53−1; **not** a client entity handle. Decimal strings are accepted server-side. | | `bone` | Case-sensitive named slot. Omitted/`""` attaches to the parent's root. At most 64 ASCII letters, digits, `_` or `-`. | | `offset` | `{x,y,z}` in metres, in the slot's local coordinate system. Missing components default to zero; each component must be finite and within ±20. | | `rotation` | `{x,y,z}` in degrees: X roll, Y pitch, Z yaw; each finite and within ±360. Applied as parent rotation × Z × Y × X. | | `contact` | Experimental native-item mouth contact: omit for automatic estimation, `false` to disable, or `{x,y,z}` in item-local metres, each finite and within ±2 (missing axes are zero). Requires an `item:Items.*` prop; ordinary meshes return `contact_requires_item`. See [item size and mouth contact](rp-animations.md#item-size-and-mouth-contact). | Native item props use the game's authored `WeaponRight` attachment on a player, with zero offset/rotation and unit scale. The contact point controls the arm's mouth target during supported consumption profiles; it does not move the grip. Automatic contact uses the container's authored local-up end (the can rim) and keeps that end while tilting; smoking uses the end nearest the mouth. Matching client/server code and animation assets are required, and visual validation of this adapter is incomplete. A vehicle binding normally uses its root, because slots differ between models: ```lua local ok, err = Open77.props.attach(propId, { parentType='vehicle', parentId=vehicleId, bone='', offset={x=0,y=0,z=1.8}, rotation={x=0,y=0,z=90}, }) ``` The prop and parent must already be in the same routing bucket. A parent has a maximum of 32 attached props across resources. Lights and looping effects are not attachable through this API. ## Server API All methods below require `world.props` and return directly (no promise). | Method | Result | | --- | --- | | `Open77.props.attach(id, binding, expectedRevision?)` | `true`, or `nil, reason`. Also reparents an already attached prop. | | `Open77.props.detach(id, expectedRevision?)` | `true`, or `nil, reason`. Detaching an already detached owned prop succeeds. | | `Open77.props.getAttachment(id)` | Binding table, or `nil` if absent/detached. | | `Open77.props.isAttached(id)` | Boolean; check the second result for a permission/runtime error. | | `Open77.props.attachedTo(parentType, parentId)` | Array of prop snapshots, sorted by ID, including props owned by other resources. | | `Open77.props.setAttachmentTransform(id, offset?, rotation?, expectedRevision?)` | Updates only the local transform. Omitted vectors retain their values; default revision is the current snapshot revision. | `Open77.props.get(id)` and `all()` include the optional `attachment` field. `parentId` in a returned binding is a decimal string. Repeated identical attaches are idempotent. An explicit stale `expectedRevision` returns `stale_revision` before any mutation. Use this to protect item handoffs: ```lua local prop = Open77.props.get(propId) if not prop then return end local ok, err = Open77.props.attach(propId, { parentType='player', parentId=recipientId, bone='RightHand', }, prop.revision) -- Update gameplay ownership only after ok, in the resource that owns the prop. ``` `setTransform` is rejected while attached. Detach first to place an object in world space. Changing its bucket to a different bucket while attached is also rejected. The server's stored position is an interest/drop anchor near the parent, **not an authoritative animated hand position**. To place a dropped item exactly, detach and use a server-validated placement position. ## Client API and diagnostics `Open77.props.bones(parentType, parentId)` returns an array of named slots for the currently rendered parent. An unavailable/unstreamed parent or a parent without named slots returns an empty array; invalid arguments, missing permissions or backend failures return `nil, reason`. Root binding needs no named slot, but the parent must still be rendered before its attached prop becomes visible. ```lua local bones, err = Open77.props.bones('player', playerId) if bones then for _, name in ipairs(bones) do print(name) end end ``` Client-created decorative props also support `Open77.props.attach(localPropId, binding)` and `detach(localPropId)`. These calls affect only props owned by the calling client resource, are not network authority and do not modify a server-owned prop. Their parent IDs are still canonical IDs, not native pointers. Client snapshots expose `attachment` and `attachmentStatus` for diagnosis. An attached prop reports `rendered=false` until its parent/slot is bound and its own visibility flag permits rendering. A missing slot hides the projection; it does not silently place the prop at the player's feet. ## Lifecycle and events Server event: ```lua AddEventHandler('onPropAttachmentChanged', function(id, current, previous, reason, revision) -- current/previous are binding tables or nil; revision belongs to this edge. -- Do not infer inventory ownership from a client observation. end) ``` Player death/disconnection, parent bucket changes and vehicle removal detach the child on the server. Removing the child also emits its terminal attachment change. Resource stop removes the child, rather than leaving an orphan binding. Streaming is different: a temporarily absent parent hides the client projection; the binding is retained and follows the new local entity when it streams back in. No local fallback body or native pointer is serialized. ## Physics, assets and failures Attached props use separately authored **visual-only mesh hosts**, with no physical mesh shape. This prevents an attached object from pushing its own carrier. Attach/detach switches host type; changing offset or reparenting an already attached prop does not require a new host. Attached props are not a rigid-body simulation and do not collide with walls. Use curated prop aliases with generated attachment hosts. Arbitrary `.ent` and raw `.mesh` paths are not supported for attachment. Server acceptance alone does not prove that a client installed the matching archive or that its current rig has the requested slot. Keep client assets aligned with the resource release. Common server refusals: `not_found`, `owned_by_another_resource`, `stale_revision`, `parent_unavailable`, `wrong_bucket`, `invalid_attachment_parent`, `invalid_attachment_bone`, `invalid_attachment_transform`, `attachment_kind_unsupported`, `attachment_parent_limit`. The bundled `prop.pickup`/`prop.drop` commands now use this binding mechanism. Their opt-in persistence still saves a dropped prop once at its server position; it does not persist a live player attachment across a restart. --- Source: https://open2077.net/docs/animated-actions # Animated actions: a pose, a prop in the hands, a real duration Your mechanic types `/repair` and the car is fixed. Nothing happened on screen. This guide turns that into: the mechanic kneels at the wheel, a welder in hand, for fifteen seconds — and everyone around sees it. Three server calls do it. Copy the examples, change the names. Both examples come from a complete roleplay server you can read and run: [the roleplay pack in Open2077/open77-resources](https://github.com/Open2077/open77-resources/tree/main/rp) — the crate convoy is [`rp_nomade`](https://github.com/Open2077/open77-resources/tree/main/rp/rp_nomade), the repair is [`rp_mecano`](https://github.com/Open2077/open77-resources/tree/main/rp/rp_mecano). ## The three calls ```lua -- 1. A pose. Everyone in range sees it. Open77.animations.play(playerId, "repair", { loop = true }) -- 2. An object in the hands. Create it, then glue it to a bone. local propId = Open77.props.create({ model = "tool.welder", position = pos, bucket = pos.bucket }) Open77.props.attach(propId, { parentType = "player", parentId = playerId, bone = "RightHand" }) -- 3. A duration. A bar on the player's screen; the call waits until it ends. local bar = Open77.exports.call("open77_uikit", "progress", playerId, { label = "Welding", duration = 15000 }) local result = bar:await() -- { ok = true } when it ran to the end ``` All three are **server** Lua. A client cannot play a pose on other people's screens, and that is on purpose. Your manifest needs: ```lua dependency "open77_props >=0.1.0" dependency "open77_uikit >=1.0.0" permissions { "world.props", "players.animations.control" } ``` ## Example 1: the mechanic repair The whole action, ready to paste into a `server/main.lua`: ```lua local repairing = {} -- playerId -> { propId, playbackId } local function stopRepair(playerId) local job = repairing[playerId] if not job then return end repairing[playerId] = nil if job.playbackId then Open77.animations.stop(playerId, job.playbackId) end if job.propId then Open77.props.remove(job.propId) end Open77.exports.call("open77_uikit", "close", playerId) end RegisterCommand("repair", function(source) local playerId = source if repairing[playerId] then return end local pos = Open77.players.position(playerId) if not pos then return end -- the pose: kneel and work local playback = Open77.animations.play(playerId, "repair", { loop = true }) -- the welder in the right hand local propId = Open77.props.create({ model = "tool.welder", position = pos, bucket = pos.bucket }) if propId then Open77.props.attach(propId, { parentType = "player", parentId = playerId, bone = "RightHand" }) end repairing[playerId] = { propId = propId, playbackId = playback and playback.playbackId } -- the duration: 15 s, cancelled with X CreateThread(function() local bar = Open77.exports.call("open77_uikit", "progress", playerId, { label = "Welding the chassis", duration = 15000, cancelKey = "X", }) local result = bar and bar:await() stopRepair(playerId) if result and result.ok then Open77.chat.send(playerId, "Chassis welded. Try not to wrap it around a pole this time.") end end) end) -- if the player walks off, the platform ends the pose; end the job with it AddEventHandler("onPlayerAnimationChanged", function(playerId, state) if type(state) == "string" then state = json.decode(state) end -- the server event carries JSON if repairing[playerId] and state and state.active == false then stopRepair(playerId) end end) AddEventHandler("playerDropped", function() stopRepair(source) end) ``` What the player sees: they kneel, the welder appears in their hand, a bar counts fifteen seconds. If they walk away or press X, everything is cleaned up in one place (`stopRepair`). ## Example 2: carrying a crate while walking Most poses are **stationary**: take a step and the platform ends them (a repair, a phone call, sitting). A few are **upper-body layers**: only the arms are animated, the legs keep walking, the camera is untouched. Today those are `carry`, `carry_pickup` and `carry_putdown`. That is what a delivery job needs. ```lua local carrying = {} -- playerId -> { propId, playbackId } local function pickUpCrate(playerId) local pos = Open77.players.position(playerId) if not pos or carrying[playerId] then return end local propId = Open77.props.create({ model = "crate.small", position = pos, bucket = pos.bucket }) if not propId then return end carrying[playerId] = { propId = propId } -- the arms come up (1.3 s), then the crate is glued to the chest Open77.animations.play(playerId, "carry_pickup") Wait(1333) Open77.props.attach(propId, { parentType = "player", parentId = playerId, bone = "Chest", offset = { x = -0.135, y = -0.60, z = 0.008 }, -- crate.small, measured in game rotation = { x = 0, y = 90, z = 0 }, }) local playback = Open77.animations.play(playerId, "carry", { loop = true }) carrying[playerId].playbackId = playback and playback.playbackId end local function putDownCrate(playerId) local job = carrying[playerId] if not job then return end carrying[playerId] = nil Open77.animations.stop(playerId, job.playbackId) Open77.animations.play(playerId, "carry_putdown") -- 2.3 s Wait(2333) Open77.props.detach(job.propId) local pos = Open77.players.position(playerId) if pos then Open77.props.setTransform(job.propId, { position = { x = pos.x, y = pos.y + 0.8, z = pos.z }, yaw = 0 }) end end ``` The player can now walk to the truck with the crate in their arms. Call these from a prompt, a command, whatever your job uses. ### Getting the offset right The numbers above are for `crate.small`. Every mesh has its own pivot and axes, so another prop needs other numbers — and guessing them by restarting the resource is slow. Add this lab command once and adjust by eye: ```lua -- console: carrytune ox oy oz rx ry rz RegisterCommand("carrytune", function(source, args) if source ~= 0 then return end local playerId = tonumber(args[1]) local job = playerId and carrying[playerId] if not job then return print("not carrying") end Open77.props.detach(job.propId) Open77.props.attach(job.propId, { parentType = "player", parentId = playerId, bone = "Chest", offset = { x = tonumber(args[2]), y = tonumber(args[3]), z = tonumber(args[4]) }, rotation = { x = tonumber(args[5]), y = tonumber(args[6]), z = tonumber(args[7]) }, }) end) ``` Try `0 90 0`, `90 0 0`, `0 0 90` for the rotation until the object is upright, then push the offset out of the body ten centimetres at a time. Write the good numbers into your config. ## Which poses can I use? `Open77.animations.get("carry")` answers the profile, or `nil` if this server does not have it. Two fields matter: | Field | Meaning | |---|---| | `kind = "workspot"` | stationary — moving, a vehicle or death ends it | | `kind = "layer"` | arms only — the player keeps walking | | `clipDurationsMs` | the real length of each clip, for one-shots | Useful ones today: `repair`, `examine` (kneel), `phone`, `smoke`, `drink`, `give`, `handsup`, `sit`, `lean`, `wounded` (stationary) — `carry`, `carry_pickup`, `carry_putdown` (walkable). The full list: [the animation catalogue](rp-animation-catalogue.md). Since catalogues grow, list what you would like and take the first one the server knows: ```lua local function firstKnown(...) for _, id in ipairs({ ... }) do if Open77.animations.get(id) then return id end end end local carryPose = firstKnown("carry", "tablet2", "phone") -- best first, fallback last ``` ## Props that make sense in a hand Use these aliases, never a raw `.mesh` path (a raw mesh renders as a white slab): | Alias | What | Where | |---|---|---| | `tool.welder` | welding torch | `RightHand` | | `container.gas_can` | jerrycan | `RightHand` | | `container.toolbox` | toolbox | at the feet (root bone) | | `food.bourbon`, `food.soda_can` | bottle, can | `RightHand` with `drink` | | `medical.device` | injector | `RightHand` | | `electronics.monitor` | a deck / screen | `Chest`, two hands | | `military.case` | a briefcase | `RightHand` | | `crate.small`, `crate.ammo_box` | boxes | `Chest`, two hands | | `garbage.bag` | loot bag | `LeftHand` | ## What a server admin can change Everything above is config: the pose name, the prop alias, bone / offset / rotation, the durations. Put them in `shared/config.lua` and any admin can retune a job without touching code — or the client. What they cannot do is add a **new** animation clip: those come with the platform's catalogue. ## When it does not work | You see | Why | Do | |---|---|---| | The pose stops as soon as I walk | it is a `workspot` profile | use a `layer` profile, or accept that the action is stationary | | `animation_owned` | another resource is already animating that player | stop yours or wait; you cannot replace another resource's pose | | `unknown_profile` | this server's catalogue has no such name | `Open77.animations.get` first, list fallbacks | | The prop is inside my body / floating | offset or rotation | tune live with `carrytune`, then save the numbers | | Nobody else sees it | you called it from a client script | move it to the server | | The bar never ends | you called `progress` outside a thread | wrap it in `CreateThread`, `await` the result | ## See also [Synchronized animations](rp-animations.md) · [Synchronized attachments](attachments.md) · [Props and effects](props-and-effects.md) · [The UI kit](ui-kit.md) · [the roleplay pack in open77-resources](https://github.com/Open2077/open77-resources/tree/main/rp) — 35 resources using all of this for real --- Source: https://open2077.net/docs/rp-kit # The role-play kit `open77_rp_basics` is a sample role-play resource combining player freeze, input blocking, synchronized animations, weapon queries and placement. It exposes cuff, escort and search actions: ```lua exports.open77_rp_basics:cuff(officerId, targetId) exports.open77_rp_basics:escort(officerId, targetId) exports.open77_rp_basics:search(officerId, targetId) ``` and one that this engine cannot do, which is therefore present and refuses: ```lua exports.open77_rp_basics:carry(officerId, targetId) --> false, "carry_unsupported" ``` Use the resource as a dependency or adapt its source. Preserve ownership tracking and cleanup so an interrupted action cannot leave a player frozen. Source: [`open77_rp_basics`](https://github.com/Open2077/open77-resources/tree/main/system/open77_rp_basics). ## What each verb is, physically | Verb | The body | The controls | Ends when | |---|---|---|---| | `cuff` | held by `Open77.players.setFrozen` — the engine's own `GameplayRestriction.NoMovement`, so every observer agrees and nothing slides | the whole gameplay action stream, on the target's own client | see [How a hold ends](#how-a-hold-ends) | | `escort` | free: he walks himself | the action stream **minus** movement | the same list, plus the tether's own endings | | `search` | held, for `searchMs` (4 s by default) | the whole stream, for the same beat | on its own, or by any of the same paths | | `carry` | — | — | it never starts | `cuff` loops the `handsup` RP profile. `escort` does not play a profile because walking cancels stationary RP animations. Stating that is better than shipping a pose that flickers off at the first step. ### `escort` is a leash There is no way to attach one player to another on 2.31, so an escort is not an attachment. The target walks under his own power with everything but movement taken away, and a 2 Hz tick watches the gap: past `tetherDistance` (5 m) he is placed back `tetherPullDistance` (1.6 m) from his escort, at the escort's height, and not again for `tetherCooldownMs` (2 s). A tether pull uses `Open77.players.teleport`, which resolves after the client reports the body settled. The cooldown prevents overlapping placements and `settle_superseded` refusals. Two tether outcomes end the hold rather than retrying: - the target got into a vehicle — `teleport` answers `player_in_vehicle` and a leash that cannot move its prisoner is not holding him; - either party died or changed routing bucket. Everything else — a stale position, a body still streaming — pauses the leash for a tick. It never ends a hold on thin evidence. ### `search` is a frisk, and reports a cache The result carries `Open77.weapons.get(targetId)` verbatim, **including its freshness fields**. That read is a cache of what the owner's client reported and never authority: a player nobody has heard from answers `weapons_unreported`, which is a different fact from "carrying nothing". The kit passes that distinction through rather than flattening it, because on an RP server the difference is an argument nobody can settle afterwards. ```lua AddEventHandler("open77_rp_basics:searched", function(officerId, targetId, ticket, result) if result.weapons == nil then -- `result.reason` is "weapons_unreported": not proof he is unarmed. return end -- result.weapons.slots / active / drawn / magazine / reportedAgeMs / loadoutAgeMs / source end) ``` ## Authority: what stops a griefer An export carries no player identity — `source` is nil inside one, by design — so the officer is an **argument**, and an argument proves nothing. Every verb therefore re-derives authority from the ACL: | Permission | Verb | |---|---| | `rp.cuff` | `cuff` | | `rp.escort` | `escort` | | `rp.search` | `search` | | `rp.admin` | `release` on somebody else's hold | Grant `rp.*` to a police role once. Until something is granted, every verb answers `not_authorised`, and that is the intended state of a fresh install: a resource that can hold one player still on another player's say-so does not arrive switched on. It is also `auto_start false` for the same reason. Before an action, the server checks that both players are ready, alive, in the same routing bucket and within `maxDistance` (3 m), using positions no older than two seconds. Dead or unready targets are rejected to avoid unsafe native actions. ## How a hold ends This is the feature. Twelve paths, each implemented, each with a test: | Reason | What happened | |---|---| | `released` | the officer asked, through `release` or `rp.free` | | `freed_by_admin` | somebody holding `rp.admin` freed him | | `expired` | the hold outlived `maxHoldMs` (15 minutes by default) | | `superseded` | the same officer replaced the hold with a different verb | | `officer_left` / `target_left` | that player disconnected | | `officer_dead` / `target_dead` | that player's life phase left `alive` | | `officer_bucket_changed` / `target_bucket_changed` | that player moved to another world | | `target_mounted` | escort only: he got into a vehicle, which a tether cannot follow | | `resource_stopping` | this resource stopped, reloaded, or hit a runtime error | **`expired` is the one that matters.** The other eleven are named paths that can each be missed — an event that does not arrive, a handler that raises, a world state nobody enumerated. The deadline is underneath all of them: a 2 Hz sweep releases anything past its expiry, every record is released through one function, and every platform call in that function is wrapped so that one refusal cannot skip the next. Nothing this kit does can strand a player indefinitely. Two further safety nets are the platform's, not the kit's, and they hold even if this VM dies between instructions: - **freeze claims and input claims are per-resource and are dropped when the owning resource stops**, on every path including a crash; - **the freeze is dropped on death and on respawn** by the life state machine itself. ### Releasing exactly what was taken Two resources claiming the same thing must not fight. A freeze is a *claim*, not a value: `setFrozen(false)` drops only the calling resource's hold, and a player stays frozen while anybody else still holds him. The same is true of every input block. So the kit writes down what it took, beside the record that asked for it, and gives back exactly that: ``` record.claims = { freeze = true, animation = "", client = true } ``` A cuff that becomes an escort releases the freeze and the animation and re-issues the client block in its walking shape — one claim each, released once each. The test suite asserts the counters return to zero on every one of the twelve endings above. ## The client half `client/main.lua` owns the control block and nothing else. Input blocking is a client-only API by design: the server decides *that* a player is held, the client decides *what that takes away*. It takes `Open77.input.blockAll` (with `except = { "Movement" }` while escorting) plus `Map` and `Hub` by name. It deliberately does **not** take the pause menu, and it cannot take chat or voice — those run on focused browser input rather than on the gameplay action stream. A player somebody else just cuffed must still be able to say so, reach settings, and disconnect. One thing the client half must get right on its own: a client-side resource reload drops its claim while the server still holds the player. On start it sends `open77:rpbasics:sync` and the server re-issues whatever is standing. ## Configuration The defaults live in `shared/rules.lua` and are what a caller gets: | Key | Default | Meaning | |---|---|---| | `maxDistance` | 3.0 m | how close the officer must be to start a verb | | `tetherDistance` | 5.0 m | how far an escorted player may drift | | `tetherPullDistance` | 1.6 m | where a pull puts him | | `tetherCooldownMs` | 2000 | the floor between two pulls | | `maxHoldMs` | 900000 | the ceiling on any hold | | `searchMs` | 4000 | how long a frisk takes | | `positionMaxAgeMs` | 2000 | older than this and a position is not evidence | `options.holdMs` on `cuff` / `escort` / `search` shortens one hold. It can never lengthen one past `maxHoldMs`: the ceiling is the operator's protection against a resource that forgets. ## Composing the verbs the row named but the kit does not export - **drag** is `escort` on a player who cannot walk. On 2.31 that is not buildable: a downed player is not `alive`, and `teleport` refuses a body that is not alive, so the leash has nothing to pull. Use `escort` once he is up. - **rob** is `search` plus your own inventory transaction. The kit deliberately stops at the report: inventory is a framework concern, not a native one, and a kit that moved items would be making decisions that belong to the gamemode. ## Least privilege, as a worked example The manifest asks for nine capabilities and a test asserts it asks for no more. Two absences are deliberate and worth copying: - **`world.vehicles` is not requested**, although knowing whether the target is seated would make the tether tidier. The teleport refusal `player_in_vehicle` answers the same question after the fact, and it costs nothing. A capability you can do without is a capability you do not ask for. - **`players.animations.read` is not requested.** The kit tracks its own playback ids and never needs to ask what somebody else started. ## See also - [Freezing a player](player-freeze.md) — the mechanism under `cuff`, and why a freeze is a claim. - [Blocking player input](input-blocking.md) — what `blockAll` really takes, and what it cannot. - [Role-play animations](rp-animations.md) — the `handsup` profile and why it is stationary. - [Cross-resource server exports](server-exports.md) — how another resource calls this one. - [Props](props.md#attachment) — the attachment that exists, and the parenting that does not. --- Source: https://open2077.net/docs/holocall-eyes # Blue holocall eyes Apply the native blue eye-glow effect to multiplayer players, including the local third-person body. The server owns this cosmetic state; clients display it on streamed bodies. It does not open the vanilla phone, play phone audio, set quest facts, restrict movement or change the player's saved eye customization. It needs a client and server build containing this API; a successful Lua call confirms canonical state, not a native rendering acknowledgement. Published in client/server **2.31.13+op77.62**, protocol **1.25**. Use matching updated builds for the server setter and its client-side projection. ## Quick start Declare the permissions your resource needs: ```lua permissions { "players.holocall.control", "players.holocall.read" } ``` In a **server** script, after validating your own call rules: ```lua local ok, reason = Open77.players.setHoloCallEyes(playerId, true) if not ok then print("Cannot enable call eyes: " .. tostring(reason)) end -- When the call ends; releases only this resource's request. Open77.players.setHoloCallEyes(playerId, false) ``` `playerId` is the connected **network player ID**, not a character record, user UUID, engine entity ID or local puppet handle. The player must be ready and alive to enable the effect. Being in a vehicle is allowed. ## API | Function | Runtime | Permission | Result | | --- | --- | --- | --- | | `Open77.players.setHoloCallEyes(playerId, enabled, options?)` | Server | `players.holocall.control` | `true`, or `false, reason` | | `Open77.players.getHoloCallEyes(playerId)` | Server | `players.holocall.read` | enabled boolean, or `nil, reason` | | `Open77.players.getHoloCallEyes(playerId?)` | Client | `players.holocall.read` | replicated enabled boolean, or `nil, reason` | `enabled` must be an actual boolean. The only option is `durationMs`: an integer from **0 to 600000**, default **0** (until explicitly released or cleaned up). A false/disable request must use 0 or omit the duration. Unknown options, numeric strings, fractional durations and invalid IDs are rejected. ```lua -- Automatic release of this resource's lease after 30 seconds. assert(Open77.players.setHoloCallEyes(playerId, true, { durationMs = 30000 })) ``` Calling true again renews this resource's duration without restarting the glow. Resources hold independent leases: one resource cannot extinguish another's active request. Use the getter if you need the effective OR-combined state. Client reads default to the local player. They are available only after the initial server snapshot and concern the current routing bucket. A false client result for an unknown/other-bucket player does not reveal their global state. There is **no client setter**. Route requests through your own server validation; do not forward a client-chosen target ID without authorization. ## State changes and cleanup Server resources can observe effective changes: ```lua AddEventHandler("onPlayerHoloCallEyesChanged", function(playerId, enabled) print(("Player %d call eyes: %s"):format(playerId, tostring(enabled))) end) ``` Both parameters are typed: integer player ID and boolean enabled. The event reports the combined state, not every redundant lease renewal. The platform automatically removes requests on death, disconnect, duration expiry, or owning resource VM stop/reload/failure. Respawning/reconnecting does not resurrect the previous call. Reapply from the phone resource if your gameplay rules explicitly resume it. No resource-stop handler is required just to release the glow. Full reliable snapshots restore the state for late joiners and routing-bucket changes. Stream-out removes the local effect; stream-in, F7 activation and body reconstruction rebind it to the new presentation. Do not manually move lights, spam a per-frame effect command or call the private replication events. The blue native effect slot is managed by this API. Avoid simultaneously starting `eye_glow_blue` through generic VFX APIs on the same body. Use a client and server build that both include this API. No additional downloaded client resource or third-party eye mod is needed. Native effects remain subject to rendering distance, streamed-body availability and the client's existing VFX budget; the getter reports synchronized intent, not a guarantee that an off-screen or occluded eye is currently drawn. ## Errors | Reason | Meaning | | --- | --- | | `permission_denied:players.holocall.control` / `.read` | Missing manifest capability. | | `invalid_options`, `invalid_argument`, `invalid_duration`, `invalid_player` | Invalid type, option or numeric range. | | `player_unavailable` | No connected/observable target. | | `player_not_ready`, `player_not_alive` | Enabling before gameplay readiness or while dead. | | `resource_stopping` | The calling VM is retiring. | | `quota_exceeded` | Cosmetic state or per-player resource lease bound reached. | | `holocall_unavailable` | Server embedding does not provide this service. | | `state_unavailable` | Client has not received a snapshot for its current session. | The API controls only the authored blue appearance. Other glow colors, eye materials, call voice routing and facial/lip animation are separate systems. --- Source: https://open2077.net/docs/weapons-api # Weapon Lua API Manage the local player's three `EquipmentArea.Weapon` slots with the asynchronous client API. Use [`open77_weapons`](https://github.com/Open2077/open77-resources/tree/main/system/open77_weapons) for authenticated server-to-owner requests and exact TweakDB IDs such as `Items.Preset_Lexington_Default`. For reload speed, recoil, advanced native statistics and impact blasts, see [Weapon customization](weapon-customization.md) and the English [rp_weapons_effect workshop](https://github.com/Open2077/open77-resources/tree/main/rp/rp_weapons_effect). Tuning requires a compatible development client and uses separate, resource-owned `setTuning`, `clearTuning` and `tuning` calls. The three-slot surface intentionally rejects heavy weapons (`WeaponHeavy`) and arm cyberware (`ArmsCW`): those equipment areas have different REDengine lifecycles. Grenades are not weapon slots either — they are `QuickSlot` gadgets thrown from a hotkey — and have their own calls, [below](#gadgets-grenades-in-the-quick-slots). Parts that go *onto* a weapon (a scope, a silencer, a mod) are [components](#weapon-components-scopes-muzzles-and-mods). ## Grenades and healing charges Use [Open77.consumables](consumables-api.md) to configure usable grenade and healing charges, select MaxDoc or Bounce Back, equip or unequip the hotkey, and control regeneration. `giveGadget` changes inventory membership; its stack count is not the number of throws. The consumables guide covers asynchronous completion, actual-use events and server-authoritative healing. ## Client permissions and methods Declare `player.weapons.read` for `slots`, `snapshot`, and `all`. Declare `player.weapons.edit` for mutation. | Method | Purpose | |---|---| | `Open77.weapons.slots()` | Return `{ 1, 2, 3 }`. | | `Open77.weapons.assign(record, slot, options?)` | Put a template in one exact slot. | | `Open77.weapons.setActive(slot)` | Select and draw an assigned slot. | | `Open77.weapons.setActive(record, options?)` | Reuse or equip a template, then draw it. | | `Open77.weapons.activate(...)` | Alias of `setActive`. | | `Open77.weapons.remove(slot)` / `unequip(slot)` | Clear a slot without deleting inventory. | | `Open77.weapons.holster()` | Holster without changing the loadout. | | `Open77.weapons.setAmmo(slot, amounts)` | Set exact spare rounds and/or magazine rounds. | | `Open77.weapons.snapshot()` / `all()` | Request every slot's verified state. | | `Open77.weapons.setComponent(slot, part, options?)` | Install a scope, muzzle or mod on the weapon in a slot. | | `Open77.weapons.removeComponent(slot, attachmentSlot)` | Empty one attachment slot of that weapon. | | `Open77.weapons.components(slot)` | List that weapon's attachment slots, taken or empty. | | `Open77.weapons.giveGadget(record, count?, options?)` | Grant grenades and put them on the throw hotkey. | | `Open77.weapons.takeGadget(record?, count?)` | Take grenades back. | | `Open77.weapons.gadgets()` | List the quick slots. | The bundled `open77_weapons` resource adds one more as an export: `exports.open77_weapons:clear()` empties all three slots at once. It is three native removals aggregated into one answer rather than three the caller has to correlate, and a slot that was already empty is not a failure. `assign` options are `active` (default `false`) and `addToInventory` (default `true`). Template-form `setActive` accepts `slot` as a placement fallback and `addToInventory`; if the template is already assigned, its existing slot wins. `setAmmo` accepts `{ reserve = 120, magazine = 18, activate = true }`. At least one count is required; an omitted count is preserved. `reserve` is the spare HUD count, while `magazine` is the loaded count and may not exceed the live weapon capacity. `activate = true` selects and draws the requested slot when needed; otherwise a non-instantiated weapon returns `weapon_not_drawn`. ```lua local requestId, reason = Open77.weapons.assign( "Items.Preset_Lexington_Default", 1, { active = true, addToInventory = true }) assert(requestId, reason) Open77.weapons.assign("Items.Preset_Katana_Default", 2) Open77.weapons.setActive(2) Open77.weapons.setAmmo(1, { reserve = 120, magazine = 18, activate = true }) ``` The returned request ID only proves that the request was validated and queued. `EquipmentSystemPlayerData` owns the actual loadout and runs in REDscript, so completion is asynchronous: ```lua AddEventHandler("open77:weapons:completed", function( requestId, operation, accepted, reason, slot, record, tweakDbId, active, drawn, ammoRecord, ammoTweakDbId, ammoTotal, ammoReserve, magazine, capacity) if accepted ~= "true" then print("weapon request failed", operation, reason) end end) ``` Client event arguments use the generic string event transport. Compare boolean fields with `"true"`, IDs with `tostring(requestId)`, and retain `tweakDbId` as an opaque hexadecimal string. A snapshot emits one state row per slot before its completion: ```lua AddEventHandler("open77:weapons:state", function( requestId, slot, record, tweakDbId, active, drawn, locked, ammoRecord, ammoTweakDbId, ammoTotal, ammoReserve, magazine, capacity) print(slot, record, active, drawn, locked) end) ``` An empty `tweakDbId` means an empty slot. `record` is restored for templates previously validated through this API; it may remain empty for an item equipped by another pipeline because shipping REDengine builds do not always retain the reverse TweakDB name table. Use `equipped`/`tweakDbId` as the presence test. Ammo fields report the shared total, spare reserve, loaded magazine and live capacity. An inactive slot can still report its ammo type and total, but its reserve/magazine/capacity are unavailable (`-1`) until the weapon object exists. ## Server-targeted API A server resource declares: ```lua dependency "open77_weapons >=0.1.0" permission "network.events" ``` It can then target the authenticated owner client: ```lua local requestId, reason = Open77.weapons.assign( playerId, "Items.Preset_Lexington_Default", 1, { active = true }) assert(requestId, reason) Open77.weapons.setActive(playerId, 1) Open77.weapons.setActive( playerId, "Items.Preset_Katana_Default", { slot = 2 }) Open77.weapons.remove(playerId, 3) Open77.weapons.holster(playerId) Open77.weapons.setAmmo( playerId, 1, { reserve = 120, magazine = 18, activate = true }) Open77.weapons.requestSnapshot(playerId) -- Every slot at once: the RemoveAllPedWeapons equivalent. Open77.weapons.clear(playerId) ``` `clear` is **one** request, not three `remove` calls. The owner runs the three native removals itself and answers once, so there is one request id to correlate instead of three and no way to end up half-cleared because one leg was refused. A slot that was already empty answers `weapon_slot_empty` natively and is **not** counted as a failure — "remove every weapon" succeeded on a slot that had none. The completion's `result` carries `cleared` and a `slots` array of `{ slot, accepted, reason }`. ## Weapon components: scopes, muzzles and mods A scope, a silencer or a generic mod is a **part** sitting in an **attachment slot** of the weapon *item*. 2.31 installs one through the vanilla `ItemModificationSystem` — the scripted system the inventory screen queues `InstallItemPart` / `RemoveItemPart` into — over two natives, `TransactionSystem.ForcePartInSlot` and `RemovePart`. Open77 drives exactly that path from the bridge script, so a part installed here is a real part: it shows on the owner's weapon, it survives holstering, and the vanilla inventory screen sees it as its own. The vocabulary is TweakDB's, not Open77's: | What | Records | Examples | |---|---|---| | Slots | `AttachmentSlots.*` | `Scope`, `PowerModule` (muzzles), `Power_Handgun_WeaponMod1` / `..2` (generic mods, one pair per evolution and class) | | Parts | `Items.*` tagged `itemPart` (928 on 2.31) | `Items.w_att_scope_short_01`, `Items.w_att_scope_long_02`, `Items.w_silencer_01`, `Items.w_muzzle_brake_01` | Which slots a given weapon has comes from its blueprint, and `components` reads it off the live item rather than guessing: a Lexington declares `Scope`, `PowerModule` and the two `Power_Handgun_WeaponMod` slots beside its receiver, barrel and magazine; a katana declares `Blade_WeaponMod1/2` and `Grip`. The full tables — 59 slots, 928 parts, every `Items.*` weapon's declared slots — are in `docs/generated/attachment-slots-2.31.csv`, `weapon-parts-2.31.csv` and `weapon-part-slots-2.31.csv`. ```lua -- Client: a short scope on the rifle in slot 1, then read the slots back. local requestId, reason = Open77.weapons.setComponent(1, "Items.w_att_scope_short_01") assert(requestId, reason) Open77.weapons.components(1) AddEventHandler("open77:weapons:part", function( requestId, slot, attachmentSlot, attachmentSlotId, taken, base, record, tweakDbId) -- one row per attachment slot; `base == "true"` is the weapon's own receiver, -- barrel or magazine, which is not a mod and cannot be removed print(slot, attachmentSlot, taken, base, record) end) ``` `setComponent` without `options.attachmentSlot` picks the first of the part's own placement slots that this weapon declares, empty ones first — so a generic mod finds the pistol slot on a pistol, where vanilla's `GetPlacementSlot` would name the rifle one. Naming the slot is refused by name when it is not on the weapon (`slot_not_on_weapon`), when the part cannot go there (`part_does_not_fit_slot`), or when it holds a base part (`base_part_slot`). A part the player does not own is created for the install, as the inventory screen's is consumed by it; a part already in the slot is swapped out and deleted, exactly as the vanilla screen does. The completion carries the changed slot as `result.part` and a `components` request carries `result.parts`. ```lua -- Server: the armoury fits a silencer to whatever is in slot 1, then lists it. Open77.weapons.setComponent(playerId, 1, "Items.w_silencer_01", { attachmentSlot = "AttachmentSlots.PowerModule" }) Open77.weapons.requestComponents(playerId, 1) Open77.weapons.removeComponent(playerId, 1, "AttachmentSlots.PowerModule") -- The synchronous read, from the cache the owner keeps fed. local fitted = Open77.weapons.components(playerId, 1) for _, part in ipairs(fitted and fitted.parts or {}) do print(part.attachmentSlot, part.record) end ``` The owner's loadout report carries the installed, non-base parts of each slot, so `Open77.weapons.get(playerId).slots[n].parts` and `Open77.weapons.components(playerId, n)` answer without a round trip, dated by the same `loadoutAgeMs` as the slot list. **What other players see.** A part changes the owner's own weapon and the server's cache. It does **not** change what other players see on the owner's proxy: the proxy weapon is rebuilt from the `ItemID` the 20 Hz snapshot carries (TweakDBID, seed, counter, structure), and parts are per-inventory-item data the `ItemID` does not encode. Shipping that visual is a wire change and is not in this release; a server that needs remote players to *see* the scope has no path yet, and one that needs to *know* about it has the cache. ## Gadgets: grenades in the quick slots Grenades are `EquipmentArea.QuickSlot` gadgets, not weapon slots. `giveGadget` grants a counted stack and equips quick slot 1 unless `equip = false`. Use a grenade record such as `Items.GrenadeFragRegular`, `Items.GrenadeEMPRegular` or `Items.Preset_Grenade_Smoke_Default`; catalogue entries use `category = grenade`. ```lua -- Client Open77.weapons.giveGadget("Items.GrenadeFragRegular", 3) -- three, equipped, on RB Open77.weapons.takeGadget("Items.GrenadeFragRegular", 1) -- one back Open77.weapons.takeGadget() -- every one of the active gadget Open77.weapons.gadgets() AddEventHandler("open77:weapons:gadget", function( requestId, quickSlot, record, tweakDbId, quantity, active, difference) -- requestId "0": the engine consumed one (the player threw it); `difference` -- is the change and `quantity` the count left end) ``` ```lua -- Server: a medic's kit, counted. Open77.weapons.giveGadget(playerId, "Items.GrenadeFragRegular", 2, { equip = true }) Open77.weapons.requestGadgets(playerId) -- And the cache: the count after the last throw, with its own age. local kit = Open77.weapons.gadgets(playerId) if kit then print(kit.active, #kit.slots, kit.reportedAgeMs) end AddEventHandler("onGadgetConsumed", function(playerId, record, remaining) print(playerId .. " threw " .. record .. ", " .. remaining .. " left") end) ``` **The consume half, and what a server sees of the explosion.** A throw is the engine removing one unit from the stack. The bridge script registers an inventory listener on the local player (the same `InventoryScriptCallback` the vanilla currency and shard toasts use), reports the shrink with request id `0`, and the `open77_weapons` client resource — which knows whether one of its own `takeGadget` requests is in flight — pushes it to the server on the reserved `open77:weapons:gadgets` transport as a consumption. The host republishes it as `onGadgetConsumed(playerId, record, remaining[, tweakDbId])`, a reserved name a resource cannot forge, and refreshes the cache behind `gadgets()`. Native grenade explosions already replicate through the thrower's snapshot and report hits to the damage arbiter. Do not also call `Open77.effects.explosion` for the same grenade: that would apply damage twice. Each detonation a player's client observed is republished on the server as the reserved event `onPlayerExplosion(playerId, sequence, x, y, z, count, bucket)`. It is the owner's **report**, not a verdict: a resource that must trust it pairs it with a consumed grenade, which is exactly what the [blast relay](#explosions-on-every-screen-the-blast-relay) does before it moves anything. Healing inhalers and other `Consumable` items are outside this API; apply healing through server-owned stats. ## Explosions on every screen: the blast relay Only the client that simulates a car can move it, and a vanilla explosion runs its physics on one client: the grenade's thrower, the missile's shooter, the simulator of the car that blew up. Without help, only that screen sees cars jump and NPCs fall. The server side of `open77_weapons` relays every explosion the server can vouch for: | Source | Accepted when | |---|---| | Grenade: `onPlayerExplosion` | paired with a frag, incendiary or cutting grenade the same player spent, reported by `onConsumableUsed` (a grenade charge) or `onGadgetConsumed` (a quick-slot stack that shrank): one detonation per grenade, within 80 m of where it was thrown (160 m for a sticky one) and 160 m of the thrower, in the thrower's bucket, three at once then one every two seconds | | Car: its canonical `exploded` flag rising | once per explosion; a wreck created as a wreck is not one | | Mounted weapon: `onVehicleWeaponExplosion` | the vehicle weapon ticket admitted the projectile's explosion (missiles, cannons and exploding rounds) | | `exports.open77_weapons:relayBlast(request)` | another server resource admitted it | For each accepted blast the relay sends `open77_weapons:blast` to the physics owner of every other car in reach, listing that owner's cars (its client pushes them with [`Open77.weapons.applyBlast`](/docs/api/client/open77-weapons#applyblast), from its own poses), grants an ownerless car to the source, and tells the nearest players within 150 m of the blast radius to knock down their own copies of living network NPCs in range. The source's own cars and NPC copies are not repeated: its engine already moved them. Radius and impulse come from server tables per explosion family (frag: radius 5.5 m); no client supplies them, and the relay deals no damage. A client without `Open77.weapons.applyBlast` ignores the relay. Knocking **players** down is a gamemode rule and is off until a gamemode asks for it: ```lua -- Server; publish again whenever this resource or open77_weapons (re)starts. local function publishBlastPolicy() local promise, reason = Open77.exports.call("open77_weapons", "setBlastPolicy", { players = true, -- launch players in reach safeZones = { { x = -1448, y = 96, z = 17, radius = 30 } }, buckets = { 0 }, -- optional }) if not promise then print("blast policy not sent: " .. tostring(reason)); return end local accepted, failure = promise:await() if accepted ~= true then print("blast policy refused: " .. tostring(failure)) end end AddEventHandler("onResourceStart", function(name) if name == GetCurrentResourceName() or name == "open77_weapons" then CreateThread(publishBlastPolicy) end end) ``` The launch is decided twice: when the blast is admitted and again just before each launch, so a player who reached a safe zone meanwhile, or a policy turned off meanwhile, is not launched. Freeroam does this with its spawn safe zones and the street bucket (its `combat.blastKnockdown` setting, on by default). A policy lapses when its resource stops; `players = false` also forbids explicit launches requested through `relayBlast`. | Control | Effect | |---|---| | convar `open77_blasts` | `off` (or `0`, `false`) turns every source off; default on | | `/weapon.blasts` (ACL `command.weapon.blasts`) | prints counters, blasts per kind and refusals by `kind:reason` | | `exports.open77_weapons:blastStats()` | the same counters as a table | `relayBlast(request)` lets another server resource relay a blast it admitted itself. The request is `{ kind = "tuned" | "scripted", source = playerId | 0, bucket (when source is 0), x, y, z, radius, push, lift, falloff?, serverSide?, players?, diagnostics?, sequence? }`. It refuses anything outside the client relay's own limits (radius above 0 and at most 60 m, push and lift 0–40 m/s with a non-zero sum, falloff 0–4, default 1), a centre more than 250 m from the source, or a dead source for `tuned`. It answers `{ vehicles, owners, characterHolders, accepted, rejected, reasons, closest, ... }` or `nil, reason` (`disabled`, `invalid_blast`, `invalid_source`, `invalid_bucket`, `position_unavailable`, `body_unavailable`, `out_of_range`, `global_rate`, `source_rate`). It is rate-limited per source player, or per invoking resource for a world blast. ```lua -- Server: a scripted blast at a fixed point in bucket 0, pushing cars and knocking down NPCs. RegisterCommand("testblast", function(source) local result, reason = exports.open77_weapons:relayBlast({ kind = "scripted", source = 0, bucket = 0, x = -1442.2, y = 127.4, z = 18.0, radius = 8, push = 10, lift = 8, }) if not result then print("blast refused: " .. tostring(reason)); return end print(("blast reached %d cars across %d owners"):format(result.vehicles, result.owners)) end, true) ``` ## Reading a player's weapons from the server, synchronously Everything above is a *request*: the answer comes back later, on an event. That is unusable inside a decision. An anticheat check, a shop validating a sale and an inventory sync reconciling a slot all have to conclude **now**, and an answer that arrives ten milliseconds later is an answer to a question nobody is still asking. `Open77.weapons.get(playerId)` answers immediately: ```lua -- manifest: permission "player.weapons.read" local weapons, reason = Open77.weapons.get(playerId) if weapons == nil then return end -- "weapons_unreported" when nothing is known print(weapons.active, weapons.activeRecord, weapons.drawn, weapons.magazine) for _, slot in ipairs(weapons.slots) do print(slot.slot, slot.record, slot.active, slot.drawn, slot.ammo and slot.ammo.magazine) end ``` | Field | Meaning | |---|---| | `slots` | one row per slot `1..3`, in the shape the relay's snapshot already answers with | | `active` | the **selected** slot, or absent when none is known | | `activeRecord`, `activeTweakDbId` | the selected weapon; the id uses the same `0x%016X` spelling as the relay | | `drawn` | the weapon is in hand. Holstering clears this without clearing `active` | | `magazine` | rounds in the drawn weapon; absent when the client did not report it | | `reportedAgeMs` | how old `active`, `drawn` and `magazine` are | | `loadoutAgeMs` | how old `slots` is | | `fresh` | `reportedAgeMs` passes the same two-second rule `Open77.players.get` applies | | `source` | `report`, `snapshot`, or the call fails | ### It is a cache, and the staleness is yours to judge **Weapons are client-owned.** They live in the client's REDengine equipment system; the server keeps no copy of that state and has no way to verify one. Every field above is **as true as the owner's last report**, and a client can lie about its own weapons exactly as it always could. Read this to *decide* — refuse a sale, flag an anomaly, reconcile an inventory — never to *assert*. Which is why there are **two** ages rather than one, and why mixing them up is the first mistake a caller makes: * `reportedAgeMs` dates the drawn weapon, whether it is in hand, and the magazine. Those ride the ordinary **20 Hz player snapshot**, which the client already sends — no extra traffic, no request. While a player is simulating this is tens of milliseconds; it stops advancing the moment they are not. * `loadoutAgeMs` dates the **slot list**, which the owner pushes only when the loadout *changes*. An untouched loadout is legitimately minutes old and that is not a fault. Rejecting it for age would reject every player who has not swapped a gun since they connected. `source` tells you which halves you have. `report` means the owner has described its slots at least once. `snapshot` means only the packet's weapon block has arrived, so `activeTweakDbId` and `drawn` are real but `slots` is empty and `active` is unknown — the packet carries an id, not a slot number. A player nobody has heard anything about at all fails with `weapons_unreported`, which is deliberately different from a player carrying nothing. ```lua -- The shape an anticheat actually wants. local weapons = Open77.weapons.get(playerId) if weapons and weapons.fresh and weapons.source == "report" and (weapons.loadoutAgeMs or math.huge) < 60000 then -- recent enough for this rule; otherwise ask, do not assume end ``` ### `onPlayerWeaponChanged` A host-wide server event, fired **once per change** and never for a report that changes nothing — a player standing still resends the same weapon block twenty times a second, and an event that repeated it would be unusable. ```lua AddEventHandler("onPlayerWeaponChanged", function(playerId, slot, record, drawn) playerId, slot = tonumber(playerId), tonumber(slot) if drawn == "true" then print(playerId .. " drew " .. record) end end) ``` Arguments are strings, like every other host event. It fires when a slot's contents change, and when the selection or `drawn` changes — reported against the slot concerned. The first report after a connection fires too, so a handler that only ever hears about changes still learns the initial loadout. ### How the cache is fed Two sources, kept apart on purpose. The **drawn weapon, whether it is in hand, and the magazine** come out of the ordinary player snapshot: `weaponTdbId`, the `WeaponValid` / `WeaponEquipped` state bits and `ammoRemaining` are already in that packet, so this half costs no extra bandwidth and no request at all. `WeaponValid` clear means "the sender did not fill this block", never "unarmed" — treating the two as the same is how an anticheat starts accusing players who are in a loading screen. **Slot membership** has no wire field, so it is pushed by the bundled `open77_weapons` client resource, which reports after every accepted mutation and once shortly after start. It does **not** poll the engine: a snapshot is a real REDengine round trip, and the loadout changes a few times an hour. Instead it compares the locally readable drawn weapon (`Open77.character.weapon()`, a field read) against what it last reported, twice in a row, and only re-snapshots when they genuinely disagree — which is what happens when a player loots a gun or swaps one through the vanilla UI. An untouched loadout costs one table read a second and zero traffic. A disconnect forgets everything about that player, so a recycled player id never inherits the previous holder's loadout. `activate` and `unequip` are server aliases. Results return only to the server resource VM that created the namespaced request: ```lua AddEventHandler("open77:weapons:completed", function( playerId, requestId, operation, accepted, reason, result) if not accepted then print(playerId, operation, reason) end end) ``` Mutation `result` contains `slot`, `record`, `tweakDbId`, `active`, `drawn`, and an `ammo` table with `record`, `tweakDbId`, `total`, `reserve`, `magazine`, and `capacity` when applicable. Snapshot `result` is an array of slot rows that additionally contain `equipped` and `locked`. The relay matches the authenticated source, request ID, and operation; unanswered calls complete with `request_timeout` after ten seconds. ## ACL-gated admin commands The official resource registers: ```text /weapon.give