Animations & roleplay
Role-play animations
How synchronized role-play animations work: install the service, build your first Lua action, use the /anim menu, manage sequences and cancellation, and understand client/server authority and temporary third person.
On this page7 sections
- How it works
- Upper-body actions for jobs and inventory
- Animation-owned items: one server call
- Item size and mouth contact
- Hold, drink, then hold again
- Third-person and first-person support
- Installation and player commands
- Quick start: your first client action
- Client Lua API
- Server Lua API
- Duration, clips and sequences
- Addressing a clip by name (TaskPlayAnim's shape)
- Portable workspots: sit, lean and lie anywhere
- Playback state
- Ownership, cancellation and events
- Local view and development diagnostics
For authored walk/run graphs, stride synchronization and server-assigned movement styles, see Custom locomotion.
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 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 for 76 profiles and 456 selectable clips. The chair, lean and lie profiles support anchored placement; see Portable workspots. 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; a whole roleplay server built on these profiles is published as the roleplay pack in Open2077/open77-resources.
How it works
- Choose a profile. A resource or the Freeroam
/animmenu 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. - 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. - 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 and 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.
-- open77.lua
resource 'my_job_actions'
server_script 'server.lua'
permissions { 'players.animations.control' }
-- 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.
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:
-- 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:
-- 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:
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 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 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
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:
/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:
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 β but for an emote a key is the better trigger,
which is what input.actions is for:
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.
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.
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:
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.
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.
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:
-- 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 })
-- 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:
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 (23,044 names)
or the workspot discovery index (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 andOpen77.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 devicecyberm\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.
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:<key> |
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:
-- 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 toOpen77.players.teleport, which costsplayers.teleportand settles the body; call it first, thenplayAt. - Placement is server-controlled.
playAtmoves 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.onPlayerAnimationChangedreports 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 withmoved, as before. A placement the channel cannot even dispatch is refused at once withanchor_move_refused(the body is not ready, not alive, or in a vehicle). - The pose is the caller's problem.
zis where the device goes, and the authored clip plays relative to it: forchairthat is the floor under the seat, forliethe floor under the bed. Put achairat seat height and the body floats. - Nothing is enumerated.
playAtdoes 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 <seconds>), but with the body already on the anchor the slide is zero either way. ObserveonPlayerAnimationChangedand theanchor_unreachedreason, not the return value. stopAton a handle whose action already ended succeeds, exactly asstopon an idle player does; another resource's handle is refused withanimation_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.
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:<capability>. 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:
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.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
