Animations & roleplay
The role-play kit
`open77_rp_basics`: cuff, escort and search composed from primitives that already shipped, how a hold always ends, and why `carry` refuses instead of faking a player-to-player attachment this engine does not have.
On this page8 sections
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:
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:
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.
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 |
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 β
teleportanswersplayer_in_vehicleand 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.
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 = "<playbackId>", 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
escorton a player who cannot walk. On 2.31 that is not buildable: a downed player is notalive, andteleportrefuses a body that is not alive, so the leash has nothing to pull. Useescortonce he is up. - rob is
searchplus 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.vehiclesis not requested, although knowing whether the target is seated would make the tether tidier. The teleport refusalplayer_in_vehicleanswers 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.readis not requested. The kit tracks its own playback ids and never needs to ask what somebody else started.
See also
- Freezing a player β the mechanism under
cuff, and why a freeze is a claim. - Blocking player input β what
blockAllreally takes, and what it cannot. - Role-play animations β the
handsupprofile and why it is stationary. - Cross-resource server exports β how another resource calls this one.
- Props β the attachment that exists, and the parenting that does not.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
