Targeting & interactions
Contextual world interactions
Custom world and NPC prompts: attaching interactions to players or NPCs, multiple actions and hold keys, the definition reference, and keeping server authority intact.
On this page7 sections
- Add the dependency
- Attach to a player or NPC
- Multiple custom actions and hold
- Updating and removing
- Definition reference
- Targets: rules instead of places
- Three cadences, and they are not interchangeable
- The target kinds
- What model can really see
- Global vehicles, and why there are no bones
- canInteract
- Group gates
- Zones
- Declaring targets from the server
- Target definition reference
- The target payload
- Refusal tokens
- Budgets
- Event payload and server authority
- A target grants nothing
open77_interactions attaches client-side action prompts to world positions or streamed entities. A transparent WebUI displays the marker, distance and action card; an allowlisted input key triggers the calling resource's local event.
This is not Cyberpunk's native interaction prompt. The point, distance label, action card, colours, copy and hold progress are rendered by the package WebUI while the target remains anchored to its 3D world position.
The prompt is fully data-driven. Utiliser is only an example: labels such as Parler Γ Jackie,
Consulter les offres, Ouvrir le coffre, or RΓ©parer le vΓ©hicule are accepted, together with a
custom description, key, short icon, colour, press/hold behavior, and up to four choices.
Source: open77_interactions.
Add the dependency
resource "jobs"
version "1.0.0"
dependency "open77_interactions >=0.1.0"
client_script "client/main.lua"
Add permission "network.events" only when the client handler itself calls
TriggerServerEvent. The interaction service cannot send a server event on another resource's
behalf.
Exports are asynchronous because every resource has an isolated Lua VM. Call them from a
CreateThread and await the returned promise:
local interaction
CreateThread(function()
local promise, callError = Open77.exports.call("open77_interactions", "create", {
id = "job_center",
position = { x = -1464.7, y = -124.3, z = 6.2 },
distance = 2.2,
markerDistance = 12.0,
marker = "arrow",
markerScale = 1.0,
markerNearScale = 2.0,
markerAnimated = true,
label = "Consulter les offres",
description = "PΓ΄le emplois de Night City",
key = "E",
icon = "JOB",
color = "#FFD84A",
event = "jobs:open",
data = { office = "city_center" },
})
assert(promise, callError)
local result, awaitError = promise:await()
assert(result and result.ok, awaitError or (result and result.error))
interaction = result.handle
end)
AddEventHandler("jobs:open", function(context)
print(context.interactionId, context.choiceId, context.distance)
end)
The service obtains the calling resource and its generation from the runtime. A resource cannot forge an owner name, update another resource's handles, or retain stale handles after a restart. Stopped owners are swept automatically.
Attach to a player or NPC
entity accepts the same opaque Open77 entity handle as Open77.character.state. Keep a 64-bit
handle as returned; do not pass it through tonumber.
local npcEntity = Open77.npcs.entity(npcId)
if npcEntity then
CreateThread(function()
local promise = assert(Open77.exports.call("open77_interactions", "create", {
id = "talk_to_receptionist",
entity = npcEntity,
offset = { x = 0.0, y = 0.0, z = 1.85 },
distance = 2.5,
label = "Parler Γ la rΓ©ceptionniste",
description = "Demander un rendez-vous",
key = "F",
icon = "DIALOG",
event = "clinic:reception",
}))
local result = promise:await()
assert(result and result.ok, result and result.error)
end)
end
An entity interaction disappears while its entity is not streamed and follows its rendered world
position when it returns. Create it after Open77.npcs.isStreamedIn(npcId) becomes true, or update
it with the new local entity handle after a stream-out/stream-in cycle.
Multiple custom actions and hold
local promise = Open77.exports.call("open77_interactions", "create", {
id = "apartment_door",
position = { x = -1448.2, y = 96.1, z = 17.5 },
distance = 2.0,
priority = 20,
choices = {
{
id = "knock",
label = "Frapper Γ la porte",
description = "PrΓ©venir les occupants",
key = "E",
icon = "DOOR",
color = "#00E5FF",
event = "apartment:knock",
},
{
id = "force",
label = "Forcer la serrure",
description = "Maintenir pendant 1,5 seconde",
key = "G",
icon = "LOCK",
color = "#FFD84A",
holdSeconds = 1.5,
event = "apartment:forceLock",
data = { difficulty = 4 },
},
},
})
Keys must be unique inside one interaction. Supported keys are AβZ, 0β9, SPACE,
ENTER/RETURN, and the four arrow keys. Input is suppressed whenever another WebUI owns
keyboard focus, so typing in chat cannot trigger a world action.
Updating and removing
local function call(name, ...)
local promise, reason = Open77.exports.call("open77_interactions", name, ...)
assert(promise, reason)
return promise:await()
end
CreateThread(function()
assert(call("setVisible", interaction, false).ok)
assert(call("update", interaction, {
label = "Le guichet est fermΓ©",
description = "Revenez Γ 08:00",
color = "#FF4D5A",
}).ok)
assert(call("setVisible", interaction, true).ok)
local current = call("get", interaction)
local owned = call("all")
assert(call("remove", interaction).ok)
-- call("clear") removes every interaction owned by this resource.
end)
setEnabled(false) suspends every interaction owned by the caller without affecting other
resources. isEnabled() returns that caller-specific state.
Definition reference
| Field | Meaning |
|---|---|
id |
Stable owner-local ID, maximum 96 characters. Generated when omitted. |
position |
Static { x, y, z } target. Exactly one of position or entity is required. |
entity |
Streamed Open77 entity handle followed through Open77.character.state. |
offset |
World offset from the position/entity origin; defaults to zero. |
distance |
Activation radius, 0.25β25 m; defaults to 2.5 m. |
markerDistance |
Maximum marker display distance, from distance to 250 m; defaults to at least 12 m. Only the winning interaction is rendered. |
showDistance |
Backward-compatible alias of markerDistance. |
marker |
WebUI motif: dot (default), ring, diamond, arrow, chevron, exclamation, info, vehicle (car alias), person, door, or shop. arrow points down toward the projected world position. |
markerScale |
Marker scale from 0.6 to 2.0; defaults to 1.0. |
markerNearScale |
Proximity multiplier from 1.0 to 4.0; defaults to 2.0. The marker progressively grows from markerScale at markerDistance to this multiplier at interaction range. |
markerAnimated |
Enables the marker's subtle vertical animation; defaults to true. |
requireLookAt |
Requires the projection to be close to the screen centre; defaults to true. |
focusRadius |
Normalized screen-centre radius, 0.02β1.0; defaults to 0.18. |
priority |
Tie-breaker from -1000 to 1000; higher wins. |
visible |
Presentation flag; defaults to true. |
cooldown |
Client presentation debounce in seconds, 0β60. Never use it as server security. |
label, description, key, icon, color, event, data, holdSeconds |
Single-choice shorthand. |
choices |
Array of 1β4 complete choice records; replaces the shorthand. |
Choice labels are arbitrary UTF-8 text up to 96 bytes, descriptions up to 160 bytes, and icons are
short textual marks up to 12 bytes. Colours use #RRGGBB. Events and IDs use letters, numbers,
underscore, colon, dash, and dot.
The arbiter renders one target at a time. An active target wins over a merely visible one, then
priority, then distance. This prevents overlapping cards from fighting each other every frame.
Targets: rules instead of places
Everything above describes one prompt bolted to one place. A shop, a job step or a vehicle interaction is not that: it is a rule β every vending machine, every vehicle, every player β and a server that had to place one interaction per object would have to enumerate the world first.
A target is that rule. It is registered once, re-resolved against what this client can currently
see, and every match is materialised as an ordinary interaction. That is the whole design: the
arbiter, the anchor budget, the card, the hold, the cooldown and the owner sweep are reused
unchanged, and a target cannot behave differently from a hand-written interaction, because by the
time anything draws it, it is one. get, all, setEnabled and the one-target-at-a-time
arbitration all see materialised matches exactly as they see create.
CreateThread(function()
local promise = assert(Open77.exports.call("open77_interactions", "addModel",
{ "Vending_Machine*" },
{
id = "vending",
distance = 2.0,
label = "Acheter une boisson",
key = "E",
icon = "SHOP",
marker = "shop",
event = "shops:vending",
}))
local result = promise:await()
assert(result and result.ok, result and result.error)
end)
AddEventHandler("shops:vending", function(context)
-- context.entity / context.engineEntity / context.class name what was hit.
TriggerServerEvent("shops:requestVending", context.engineEntity)
end)
Three cadences, and they are not interchangeable
| What | How often | Where |
|---|---|---|
| Where a prompt is drawn | every rendered frame | the plugin, through the anchor the materialised entry owns |
| What it says, and whether a key fired | every Lua tick (33 ms) | this resource |
Which things match a rule, and whether canInteract still agrees |
every 250 ms | this resource |
A world query and a canInteract predicate are both far too expensive for the tick, and neither
answer changes meaningfully inside a quarter of a second. Nothing about the slower cadence makes a
prompt lag: a match anchored to an entity is followed per frame by the plugin the instant it exists.
The only thing the resolve cadence bounds is how quickly a prompt appears on something new.
The winning match β the one a key is about to fire on β is re-checked on a faster clock, at most every 500 ms, so a prompt that stops being allowed disappears rather than waiting out a full pass.
The target kinds
kind |
Matches | Anchored to | Source |
|---|---|---|---|
model |
anything whose TweakDB record or RTTI class matches models |
the entity when one is known, else the queried point | Open77.world.nearby, Open77.vehicles.all, Open77.npcs.all |
class |
anything whose RTTI class matches classes |
same | Open77.world.nearby |
globalVehicle |
every streamed vehicle | the vehicle entity | Open77.vehicles.all |
globalPlayer |
every nearby player but you | the player's body entity | Open77.players.nearby |
globalNpc |
every streamed puppet that is not a player body | the entity for an Open77 NPC, else the queried point | Open77.npcs.all + Open77.world.nearby(r, "puppet") |
zone |
one prompt, only while you stand inside a volume | the zone's own centre | the declaration |
addModel, addClass, addGlobalVehicle, addGlobalPlayer, addGlobalPed, addZone,
addBoxZone and addSphereZone are sugar: each is addTarget with kind filled in, so there is
one implementation and one set of refusal tokens.
Selectors (models, classes) are matched case-insensitively and exactly, with one optional
trailing * for a prefix β "Vending_Machine*". They are deliberately not Lua patterns: a resource
must not be able to hand this service a pattern that backtracks for a millisecond per entity.
What model can really see
Unlike FiveM's uniform model hash, model identity in Cyberpunk 2.31 depends on the candidate's source:
| Source | RTTI class name | TweakDB record |
|---|---|---|
Open77.world.nearby |
yes (className) |
no |
Open77.vehicles.all |
joined from the world query when both see it | yes (record) |
Open77.npcs.all |
no | yes (record) |
There is no generic "entity handle to record id" call in the client Lua surface at all. So:
classis exact and complete. It matches the RTTI class name the world query reports, and that is what actually gets you vending machines, ATMs and chairs today β REDengine models those as device and prop classes.modelmatches either name a candidate can carry: the record when the source knows one (vehicles and Open77-spawned NPCs), the class name otherwise. Amodeltarget aimed at a prop therefore degenerates to a class match, and that is not a bug to be worked around β it is the only identity the engine hands a script for that object.
If you want a record match and only a record match, put the record in canInteract's declarative
record selector, which never falls back to the class name.
Global vehicles, and why there are no bones
Vehicle interaction prompts cannot target arbitrary named bones such as trunk, hood or doors. Use the part-targeting support described below.
- Cyberpunk authors slot names per entity. An earlier row in this campaign established it, and
nothing in the client Lua surface lists an entity's slots. The only native slot walk in the
client (
DescribeOccupantAnchor,client/src/api/Vehicles.cpp) readsentSlotComponent::slotsoff the live entity and is reachable only from the debug bridge. A fixedtrunk/hoodvocabulary would be a vocabulary this engine does not have. Open77.vehicles.doorsis not a slot table.{ frontLeft = 0, β¦ trunk = 4, hood = 5 }are bit indices into the snapshot'sdoorsmask. They say which bit means which door. They carry no transform.- Even a hand-measured offset would point the wrong way.
Api::WorldAnchors::Resolveadds an anchor's offset to the entity'sGetWorldPosition()in world axes, without rotating it into the entity's frame. An offset meaning "behind the car" would mean "north of the car" the moment the car turned.
What is offered instead is real, and it is what a trunk interaction actually needs: part.
Open77.exports.call("open77_interactions", "addGlobalVehicle", {
id = "trunk",
distance = 3.0,
choices = {
{ id = "open", label = "Ouvrir le coffre", key = "E", part = "trunk",
canInteract = { vehicle = { partOpen = false, locked = false } },
event = "garage:openTrunk" },
{ id = "close", label = "Fermer le coffre", key = "E", part = "trunk",
canInteract = { vehicle = { partOpen = true } },
event = "garage:closeTrunk" },
},
})
part is one of frontLeft, frontRight, backLeft, backRight, trunk, hood. It is a
state selector, not an anchor: the card still sits on the vehicle, and the payload gains part,
partIndex and partOpen, decoded from the snapshot's door mask. That is enough to build the
open/close pair above, which is the interaction people actually want, without claiming a position
the engine will not give.
part may be set on the target (applying to every choice) or per choice, and a choice's own part
wins. It is refused on any kind but globalVehicle (part_requires_global_vehicle).
canInteract
Every target and every choice may carry canInteract. It has two forms, and the reason there are
two is the export boundary: functions never cross it (see
Cross-resource exports, "What crosses, and what does not"), so a closure
cannot be handed to this service the way ox_target takes one.
A string names one of your own exports. It is invoked with Open77.exports.callSync, which runs
it inline, in your VM, with your own upvalues β a closure in everything but spelling.
-- jobs/client/main.lua
exports("canRepair", function(payload)
return payload.vehicle.locked == false and payload.vehicle.health < 100.0
end)
Open77.exports.call("open77_interactions", "addGlobalVehicle", {
id = "repair", distance = 3.0, label = "Reparer", key = "E",
event = "jobs:repair", canInteract = "canRepair",
})
A table is a declarative rule, evaluated in this service with no call at all. Prefer it whenever it fits:
canInteract = {
groups = { "police", "ems" }, -- any of, from the player's own state bag
notGroups = { "banned" }, -- none of
minDistance = 0.0,
maxDistance = 1.5,
state = { ["job.onDuty"] = true }, -- equality against the local player's bag
class = { "VendingMachine*" },
record = { "Vehicle.v_standard2_*" },
vehicle = { locked = false, occupied = false, driven = false, partOpen = false },
}
Every key is optional; all present keys must hold. state keys may be dotted to read one level
into a bag value, and take at most eight entries.
The evaluation rule
canInteract is evaluated at prompt-resolution time. It is never evaluated per frame, and never
per tick. Concretely: once when a match is materialised (so at most every 250 ms per match), and
again at most every 500 ms while that match is the entry the arbiter chose. The result is cached on
the materialised entry.
Four consequences worth knowing before you write one:
- It must not wait.
Wait,promise:await()and anything built on them fail the call withexport_yielded. This is enforced by the runtime, not by this document:callSynchas no scheduler underneath it. - It must not ask the server.
Open77.net.callis a network round trip, and a predicate that makes one β even once per resolve pass, even per vehicle on a busy street β is a performance bug and a source of prompts that flicker as answers arrive late. If a predicate needs server truth, have the server write that truth into the player's state bag once and read it with thestateorgroupsrule, which is free. - It fails closed. A predicate that raises, that tries to wait, or that belongs to a resource that has since stopped refuses the prompt. It never grants one by accident. The refusal is printed once per ten seconds, naming the owner and the export.
- The payload carries what it needs.
entity,distance, the class, the record, the vehicle's own state and the target'sdataare all in the payload already, so the common predicate needs no lookup of its own.
When no choice on a materialised match is allowed, the whole match leaves the arbitration rather than drawing a card that refuses to do anything β and so stops shadowing whatever is behind it.
If one resolve pass spends more than 8 ms in predicates, the service says so, once per ten seconds.
That message means somebody's canInteract is too slow, not that the service is busy.
Group gates
groups on a target is the cheap gate: a target whose groups the player does not have resolves to
nothing at all, before any candidate is examined.
Open77.exports.call("open77_interactions", "addGlobalVehicle", {
id = "impound", groups = { "police" }, distance = 3.0,
label = "Mettre en fourriere", key = "G", event = "police:impound",
})
Membership is read from the player's own state bag, and Open77 does not gain a third permission model for this. The two that exist were both considered:
- The ACL (
Open77.acl.isAllowed,Open77.acl.roles) is the right authority β and it is server-only. There is no client binding for it at all, and a target gate runs on the client. Routing every gate through a callback would put a network round trip in the resolve pass, which is exactly what thecanInteractrule above forbids. - State bags are already replicated, server-written, client-readable synchronously, and are where an RP framework puts a player's job in the first place.
So the gate reads Open77.state.localPlayer() and accepts any of:
| Bag key | Shape | Example |
|---|---|---|
groups |
array of names | { "police", "ems" } |
groups |
set of names | { police = true } |
group |
one name | "police" |
job |
one name | "police" |
job |
table with name |
{ name = "police", grade = 3 } |
Names are matched exactly and are case-sensitive. The server writes them:
-- server side, manifest requires state.write
Open77.state.player(playerId):set("job", { name = "police", grade = 3 })
The ACL keeps its job, which is the one that matters: when the intent arrives, the server calls
Open77.acl.isAllowed(source, "job.police.impound") before doing anything. The bag decides what the
player sees; the ACL decides what the player gets.
Zones
A zone target materialises one prompt, at the volume's own centre, only while the player is inside
it. Three shapes:
-- A box: centre, size, optional heading in degrees about Z.
Open77.exports.call("open77_interactions", "addBoxZone", {
id = "counter", position = { x = -1462.0, y = -120.0, z = 6.2 },
size = { x = 4.0, y = 2.0, z = 3.0 }, heading = 35.0,
distance = 3.0, label = "Commander", key = "E", event = "bar:order",
})
-- A sphere (a cylinder, really: `maxHeight` bounds it vertically).
Open77.exports.call("open77_interactions", "addSphereZone", {
id = "yard", position = { x = 0, y = 0, z = 0 }, radius = 12.0, maxHeight = 4.0,
distance = 6.0, label = "Garer", key = "E", event = "garage:park",
})
-- A polygon in the XY plane, with an explicit height band.
Open77.exports.call("open77_interactions", "addZone", {
id = "lot",
zone = { shape = "poly", minZ = 4.0, maxZ = 9.0,
points = { { x = 0, y = 0 }, { x = 10, y = 0 }, { x = 10, y = 8 }, { x = 2, y = 6 } } },
distance = 8.0, label = "Deposer", key = "E", event = "jobs:drop",
})
Containment is the gate on whether the prompt exists at all; distance, requireLookAt and
focusRadius still govern the card, exactly as for every other target. Set distance to cover the
part of the zone you want the card readable from (it is capped at 25 m, like any interaction), or
give the target its own position to anchor the card somewhere other than the volume's centre.
Zone targets use synchronous contains(shape, point) queries over normalized shape tables during the resolve pass. Avoid asynchronous export calls in this path.
Declaring targets from the server
A job resource should describe its targets once and have every client apply them. Doing that per client from a client script means every gamemode reimplements the same fan-out and gets late joins wrong.
-- jobs/open77.lua
resource "jobs"
version "1.0.0"
dependency "open77_interactions >=0.2.0"
server_script "server/main.lua"
client_script "client/main.lua"
permissions { "network.events" }
-- jobs/server/main.lua
CreateThread(function()
local ok, reason = exports.open77_interactions:define({
{
id = "atm",
kind = "model",
models = { "AtmMachine" },
distance = 1.6,
label = "Retirer de l'argent",
key = "E",
icon = "ATM",
event = "bank:withdraw",
canInteract = { maxDistance = 1.4 },
},
{
id = "repair",
kind = "globalVehicle",
groups = { "mechanic" },
distance = 3.0,
label = "Reparer",
key = "R",
event = "jobs:repair",
},
})
if not ok then print("interactions: " .. tostring(reason)) end
end)
The wire is an ordinary authenticated net event, open77:interactions:targets, not a new protocol
message. The client half trusts the owner in it precisely because it can only have come from the
server; a client cannot forge one. Applying a declaration is idempotent: it retracts that owner's
previous set first, so a re-define replaces rather than accumulates.
Late joins and reloads are covered from both ends. The server pushes on onPlayerConnected, and
this resource asks for the standing declarations with open77:interactions:sync whenever it starts.
undefine(id) withdraws one; undefine() and clear() withdraw all of them. A declaring resource
that stops or reloads is retracted automatically, within two seconds, by a generation sweep β
the client half could never notice this on its own, because a server-only job resource has no client
VM whose stop it could observe.
Two rules follow from the split, and both are load-bearing:
- A server declaration's
canInteractexport name resolves against the declaring resource's client VM. The predicate runs on the client, so the export must be published there. A declarative table needs no client half at all. - A server-declared target is not removed when the declaring resource's client half stops. It belongs to the server and only the server retracts it. A client-declared target, as before, goes with its client VM's stop or reload.
A declaration is bounded: at most 32 targets per resource and roughly 16 KB per declaration
(declaration_too_large), because the server fans it out to every client on every join.
Target definition reference
Everything in the Definition reference above is accepted and behaves
identically β distance, markerDistance, marker, markerScale, color, priority,
requireLookAt, focusRadius, cooldown, offset, visible, label/key/icon/event/data
and choices. A target is validated by the same normaliser a hand-written interaction goes
through, so it can never accept a presentation create would reject. On top of it:
| Field | Meaning |
|---|---|
kind |
Required. One of model, class, globalVehicle, globalPlayer, globalNpc, zone. |
id |
Stable owner-local id, maximum 64 characters. Generated when omitted. |
models |
model only. One selector or an array of up to 32, matched against record then class. |
classes |
class only. One selector or an array of up to 32, matched against the RTTI class. |
zone |
zone only. { shape = "sphere"|"box"|"poly", β¦ }; see Zones. |
part |
globalVehicle only. frontLeft, frontRight, backLeft, backRight, trunk, hood. Also settable per choice. |
groups |
Up to 32 group names. The target resolves to nothing unless the player has one. |
canInteract |
Export name (string) or declarative rule (table). Also settable per choice. |
maxMatches |
How many matches may be live at once, 1β32; defaults to 8. |
A materialised match keeps its own interactionId, which is <targetId>:<matchKey> β stable for
as long as that thing is matched, so open77:interaction handlers can key off it.
The target payload
A materialised match adds these to the event payload, and
they are also exactly what a canInteract predicate receives:
| Field | Present for | Meaning |
|---|---|---|
targetId |
all | The target's own id. |
kind |
all | The target kind that matched. |
entity |
when known | The Open77 entity handle β the same domain as Open77.players.entity. |
engineEntity |
when known | The raw engine entity handle the world query reported. |
class |
when known | The RTTI class name. |
record |
when known | The TweakDB record (vehicles and Open77 NPCs). |
distance, position |
all | Where it was, at resolution time. |
data |
all | The target's own data table. |
playerId, playerName |
globalPlayer |
Who it is. |
npcId |
Open77 NPCs | The replicated NPC id. |
vehicleId, vehicle |
vehicles | { id, entity, record, locked, health, speed, doors, windows, occupants, occupied, driver }. |
part, partIndex, partOpen |
part targets |
The selected door bit and whether it reads open. |
zoneId |
zone |
The zone target's id. |
choiceId |
per-choice predicates | Which choice is being asked about. |
Refusal tokens
addTarget and its sugar answer { ok = false, error = "<token>" }; the server's define answers
false, "<token>". Every presentation token from create still applies. On top of them:
| Token | Meaning |
|---|---|
invalid_target_kind |
kind missing or not one of the six. |
invalid_target_id |
Not 1β64 characters of [A-Za-z0-9_:.-]. |
duplicate_target_id |
This owner already has a target with that id. |
invalid_models, invalid_classes |
Empty, over 32 entries, or an unusable selector. |
invalid_zone, invalid_zone_shape, invalid_zone_position, invalid_zone_size, invalid_zone_radius, invalid_zone_heading, invalid_zone_points, invalid_zone_height |
The zone shape is not usable. |
invalid_groups |
Empty, over 32 entries, or a name outside [A-Za-z0-9_:.-]. |
invalid_can_interact |
Not an export name and not a well-formed declarative rule. |
invalid_max_matches |
Not an integer in 1β32. |
part_requires_global_vehicle |
part on any other kind. |
invalid_vehicle_part |
Not one of the six door bits. |
target_limit |
64 targets across every resource. |
target_not_found, not_owner |
removeTarget/getTarget against something you do not own. |
definitions_must_be_a_table, invalid_target_count, declaration_too_large, no_declaration |
Server define/undefine. |
Budgets
| Limit | Value | Why |
|---|---|---|
| Targets | 64, across every resource | |
| Materialised matches | 96, across every resource | Deliberately below the 256 interaction cap, so a rule matching a crowded street can never starve the interactions a gamemode placed on purpose. |
| Matches per target | 8 by default, 32 maximum | |
| Selectors per target | 32 | |
| World scan radius | the widest live markerDistance, capped at 120 m |
|
| Resolve cadence | 250 ms | |
| Winner re-check | 500 ms | |
| Predicate budget | 8 ms per resolve pass before the service complains |
Event payload and server authority
Every selected choice emits its custom event and the common open77:interaction event with:
{
interactionId = "job_center",
handle = 1,
owner = "jobs",
choiceId = "primary",
key = "E",
position = { x = 0, y = 0, z = 0 },
distance = 1.4,
data = {},
interactionData = {},
}
This payload is client presentation evidence, not authority. If an action changes shared state, the owner may submit only the minimum intent:
-- client/main.lua; manifest requires network.events
AddEventHandler("jobs:open", function(context)
TriggerServerEvent("jobs:requestOpen", context.interactionId)
end)
-- server/main.lua
RegisterNetEvent("jobs:requestOpen", function(interactionId)
local playerId = source -- authenticated by Open77; never accept it from the payload
if interactionId ~= "job_center" then return end
local position = Open77.players.position(playerId)
if not position then return end
-- Recheck routing bucket, distance, role/ACL, cooldown, and authoritative
-- job-centre state here before opening or mutating anything.
TriggerClientEvent("jobs:openMenu", playerId, { office = "city_center" })
end)
The WebUI never receives input focus and builds all copy with DOM textContent; labels cannot
inject HTML. The service intentionally does not claim line-of-sight or server-side proximity.
One target kind already does this round trip for you. A choice used on a globalNpc target
that resolved to an Open77 NPC (the payload carries npcId) is also reported to the server by
this resource, on the reserved open77:npcs: transport, as the minimum intent β which NPC, which
prompt, which choice. The server re-derives the NPC, the routing bucket and the distance from its
own copy of the player (refusing a report from a player it cannot place, from another bucket, or
from more than 40 m away β 25 m is the widest prompt, plus a two-second snapshot at sprint speed)
and only then publishes onNpcInteracted(npcId, playerId, interactionId, choiceId, distance)
to every server resource, with distance the server measured. A shop with an NPC clerk therefore
needs no client half and no net event of its own:
-- shop/server/main.lua
CreateThread(function()
exports.open77_interactions:define({
{ id = "clerk", kind = "globalNpc", distance = 2.0, label = "Browse", key = "E", event = "shop:browse" },
})
end)
AddEventHandler("onNpcInteracted", function(npcId, playerId, interactionId, choiceId, distance)
if clerks[npcId] == nil or tonumber(distance) > 3.0 then return end -- your rule, on the server's number
TriggerClientEvent("shop:openMenu", tonumber(playerId), clerks[npcId].catalogue)
end)
See NPC events for the event and its arguments.
A target grants nothing
A prompt is presentation, and a target makes that easy to forget, because it looks like a rule the system is enforcing. It is not. Every part of a target is evaluated on a machine the player owns:
| What the target seems to enforce | What it actually is | What the server must do |
|---|---|---|
groups / canInteract |
a client read of the local player's own state bag, or a client-side predicate | call Open77.acl.isAllowed(source, β¦), or re-read the player's authoritative role |
distance / markerDistance |
a client distance check against a client-read position | recompute from Open77.players.position(source) |
models / classes / kind |
what a client-side world query reported | re-resolve the target object server-side, or accept only an id you can verify |
vehicle.locked, partOpen, occupied |
a snapshot the client replicated in | read the authoritative vehicle state |
entity, engineEntity, vehicleId, playerId |
numbers the client put in an event | treat as a claim; verify the player may act on that object at all |
A modified client can call TriggerServerEvent with any payload it likes, with no prompt on screen
at all. The correct shape of a target handler is therefore the same as any other: accept the
smallest possible intent, and re-derive everything else.
-- server/main.lua
RegisterNetEvent("garage:openTrunk", function(vehicleId)
local playerId = source -- authenticated by Open77; never accept it from the payload
if type(vehicleId) ~= "number" then return end
if not Open77.acl.isAllowed(playerId, "job.mechanic.trunk") then return end
-- Then: is that vehicle real, in this player's bucket, within reach, and is
-- its trunk actually shut? The prompt answered none of those questions.
end)// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
