Quick navigation

Go to

Players & movement

Freezing a player

Hold a player's body still: the authoritative server freeze on the engine's own movement restriction, the presentation-local client hold, what a freeze stops and what it deliberately does not.

6 min read1,366 wordsMarkdown
On this page7 sections

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.

-- 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:

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.

-- 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:

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.

-- /free <id>: 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

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try