Cameras & cinematics
Scripted cameras
Put the view where your resource wants it: create, place, follow, look at and shake a camera, spectate a player from the server, and the release rules that guarantee the player gets their own view back.
On this page8 sections
- The model in one page
- What is reachable, and what is not
- The release rules
- How it composes with first/third person
- Spectating
- Move the ghost to the target
- Every way it ends
- Refusals
- What a single client cannot prove
- Reference
- Open77.camera.create(options) -> camId | nil, reason
- Open77.camera.setTransform(camId, position?, rotation?)
- Open77.camera.setTransform(camId, { position =, rotation =, fov = })
- Open77.camera.lookAt(camId, position | entity)
- Open77.camera.attach(camId, entity, offset?)
- Open77.camera.detachFrom(camId)
- Open77.camera.activate(camId, { blendMs }) -> true, promise | false, reason
- Open77.camera.deactivate({ blendMs }) -> true, promise | false, reason
- Open77.camera.destroy(camId)
- Open77.camera.shake(camId?, preset, amplitude?, ms?)
- Open77.camera.stopShake(camId)
- Open77.camera.cameras() -> table
- Open77.camera.unproject(x, y) -> { origin, direction }
- Open77.camera.follow(entity, options?) -> camId, promise | nil, reason
- Unchanged calls
- Why camera.script and not camera.preview
- Failure tokens
- Worked example: a character creator
- Integration checklist
Use camera.script to control cinematic cameras. For the playable F7 view, shoulder framing, movement FOV and camera shake, use Third-person camera styles with camera.style.
For an independent feed on a HUD, WebUI or world screen without taking over the player's view, use Remote cameras and world screens with camera.capture.
Put the view where your resource wants it β a character creator orbiting the player's face, a shop that frames the rack, a dealership turntable, a cinematic intro β and be certain the player gets their own view back.
Client only, and everything here is behind the camera.script permission β
except Spectating, which is driven from the server with
players.spectate and needs no client resource at all.
local cam = assert(Open77.camera.create({
position = vector3(pos.x, pos.y - 2.0, pos.z + 1.7),
lookAt = 0, -- entity 0 is the local player's body
fov = 45,
}))
local ok, blended = Open77.camera.activate(cam, { blendMs = 600 })
if not ok then print('no camera: ' .. tostring(blended)); return end
blended:await() -- the shot is up
-- ... later ...
Open77.camera.deactivate({ blendMs = 400 })
Open77.camera.destroy(cam)
The model in one page
A camera is a definition, not a view. create makes a camera and returns an
id; nothing is rendered until you activate it. You may hold as many
definitions as you like (16 per resource), and exactly one of them can be the
live view at a time.
Ownership is per resource, and the view is per client. A camera belongs to the resource generation that made it β another resource cannot move it, point it, shake it or destroy it. The view belongs to at most one resource at a time: a second resource asking for it is refused, with the holder named, and never silently served.
local ok, reason = Open77.camera.activate(cam)
-- ok == false, reason == "camera_held_by:open77_creator"
That refusal is the whole design. Stealing the view would leave two resources each believing they are driving, and the one that is not would keep issuing writes that vanish.
Everything is world space β position and lookAt are world coordinates,
except an attached camera's offset, which is in the parent entity's own frame
(X right, Y forward, Z up). Vectors and plain { x =, y =, z = } tables are
accepted everywhere, and every vector this API returns is a plain table that
vector3() accepts.
Failures are values. Every call returns nil, reason or false, reason
with a stable snake_case token. The complete list is at the bottom of this page.
What is reachable, and what is not
Camera controls are limited to the native capabilities listed below.
Reachable. A world position, a world orientation, a look-at that tracks a point or an entity every frame, an attachment that follows a moving parent, a field of view, a blended activation and deactivation, a shake, and the screenβworld ray.
Not reachable, and why:
- A camera independent of the player's body. 2.31 has no creatable camera object. The only camera component whose transform writes are both accepted and rendered is the one on the player, and it is parented to the body. Open77 converts your world pose into that component's local frame every frame, which is what makes a world position work at all β but the camera is still a child of the player. If the body goes, the camera goes.
- Streaming follows the player, not the lens. A camera parked far from the
body renders whatever is streamed for the body: missing geometry, missing
NPCs, unloaded interiors.
activatetherefore refuses a camera more than 250 m from the player withinvalid_argument, so the failure is a named refusal rather than a grey frame. Move the player if you need to move the shot that far. - One view at a time. Many definitions, one active.
- No collision. A camera placed inside a wall is inside the wall. The third-person rig's spring arm does not apply here, deliberately: a cinematic camera that refuses to go where it is told is worse than one that clips.
The release rules
A stuck camera is indistinguishable from a crash. Read this section before you write the shot.
The view comes back by itself, on the next frame, in every one of these cases. You do not have to handle them, and you cannot opt out of them:
| What happens | Reason your pending promise is rejected with |
|---|---|
| Your resource is stopped | resource_stopped |
| Your resource is reloaded | resource_stopped (the new generation starts with nothing) |
| A Lua coroutine in your resource errors | resource_error (your camera definitions go too β the coroutine that was going to tidy up is dead, so re-create after an error) |
| The player dies | player_died |
| The player's body becomes unreadable for ~half a second | player_unavailable |
| The world unloads, the player disconnects, the session ends | world_exit |
| The client shuts down | client_shutdown |
| You destroy the camera you are looking through | camera_destroyed |
| You activate a different camera of your own | camera_superseded |
You call deactivate |
camera_deactivated |
Two consequences worth writing down:
- A camera does not survive a death. After a respawn the camera definition
still exists, so you can put the shot back up with one
activateβ but you have to ask. That is on purpose: the engine's own death camera writes the same component, and competing for it is a fight nobody wins. - Never leave an
awaitwithout a failure branch. Every release above rejects the promise rather than dropping it, precisely so your coroutine wakes up and can tidy the rest of your state.
local ok, blended = Open77.camera.activate(cam, { blendMs = 600 })
if ok and blended then
local _, why = blended:await()
if why then
-- the shot never happened: the player died, we were reloaded, something
-- took the view. Put the UI back the way it was.
closeCreatorUi()
return
end
end
How it composes with first/third person
Open77.perspective and the arbiter behind it own six presentation values
together β camera, body, input, weapon, effects, audio β and change them as one.
A scripted camera does not sneak past that; it tells it.
While you hold the view, the arbiter reports:
camera=engine reason=scripted_camera
which is the same answer it gives for a cutscene: stand down, do not compete for the camera, do not take back a camera we did not lend. The third-person rig is switched off for the duration and restored when you let go.
Three things follow:
activaterefuses while a perspective transition is in flight, withcamera_unavailable. Retry a frame later. Taking the camera mid-transition would leave the body and the view disagreeing for as long as the transition runs.- Death and ragdoll outrank you. Both fall back to first person, and the registry releases your camera from its own side at the same moment, so the two never disagree.
- The arbiter never moves the player. Neither does this API. A scripted
camera is presentation only: the body stays exactly where gameplay put it,
which is why a player can walk out from under your shot. If you need them to
stay put,
Open77.character.setFrozenis the tool, and it is a separate, separately-permissioned decision.
Spectating
Server side, permission players.spectate. One call puts an admin behind
another player's shoulder and takes his body out of the world while he is there.
Open77.players.spectate(adminId, suspectId) -- start
Open77.players.spectate(adminId, false) -- stop, and give the body back
Three things happen together, and they are one transaction:
- the spectator is ghosted β he cannot block, body-check or be walked into;
- his body is hidden β nobody sees an admin standing in the fight he is filming, and his nameplate goes with him;
- his client is told to put a
followcamera on the target.
If the body cannot be taken away, nothing is applied and nothing is sent. That order is the point: the half that can strand a player is the body, so it goes first and it fails closed. The camera is the half that can only fail temporarily β an unstreamed target, a resource already holding the view β and it retries by itself every frame until it takes.
Open77.players.spectate(adminId, suspectId, {
blendMs = 400, -- 0..10000, how long the cut takes
distance = 5.0, -- metres behind the target
height = 2.0, -- metres above
})
Reads, both behind players.life.read:
Open77.players.spectating(adminId) --> targetId, or 0
Open77.players.spectators(suspectId) --> { adminId, ... }
Move the ghost to the target
The world streams around the spectator's own body, not around the camera. A target on the other side of Night City gives a correctly-aimed camera looking at unstreamed space. The spectator is invisible and non-solid by then, so landing on top of somebody costs nothing:
Open77.players.spectate(adminId, suspectId)
Open77.players.teleport(adminId, Open77.players.position(suspectId))
Do the same again whenever the target travels far β a vehicle chase across two districts will outrun the spectator's streaming otherwise.
Every way it ends
This is the list that matters, because a camera you cannot give back reads to a player exactly like a crash, and an invisible admin is worse than a stuck camera. Each of these restores both halves:
| It ends when | Reason token |
|---|---|
| the caller stops it | whatever the caller passed |
| the target dies, or is revived or respawned | target_not_alive |
| the spectator dies | spectator_not_alive |
| either side disconnects | target_gone / spectator_gone |
| either side changes routing bucket | bucket_changed |
| either side reconnects | reconnected / target_reconnected |
| the resource that started it stops, reloads or errors | owner_released:<owner> |
| the client's session changes (host-side) | the camera is dropped locally |
The spectator's body is restored to what it was, not to a default: an admin a jail script had already ghosted stays ghosted, and a body another resource has since hidden stays hidden. Only the ghost and the hide this session made are undone.
Retargeting β spectate(adminId, otherId) while a session is live β swaps the
shot without ever putting the body back in the world for a frame.
Refusals
| Token | Meaning |
|---|---|
permission_denied:players.spectate |
the manifest does not declare it |
invalid_player / invalid_target |
not a positive integer id |
cannot_spectate_self |
a player cannot watch his own shoulder |
player_not_found / target_not_found |
no life state for that id |
spectator_not_alive / target_not_alive |
one of them is dead or mid-transition |
different_bucket |
the target's body is not streamed to the spectator at all |
target_is_spectating |
chains are refused: the middle player's camera is already elsewhere |
not_spectating |
stopping a session that does not exist |
invalid_argument |
a blend, distance or height outside its range |
life_unavailable |
the host has no life service (a test harness) |
What a single client cannot prove
Spectating has no meaning with one client. On one machine you can prove the refusals, the permission gate and that the body comes back; you cannot prove that the picture is of somebody else. Two clients, two accounts β see the integrator scenario in the row's report.
Reference
Open77.camera.create(options) -> camId | nil, reason
| Option | Type | Meaning |
|---|---|---|
position |
vector3 | World position. Required unless attachTo is given. |
attachTo |
entity | Parent the camera to an entity. 0 is the local player's body. |
offset |
vector3 | Offset in the parent's frame (X right, Y forward, Z up). Accepted as position too. |
lookAt |
vector3 or entity | A world point, or an entity to track. Re-solved every frame. |
rotation |
table | Used when there is no lookAt. Either a quaternion { x, y, z, w } or Euler degrees { pitch, yaw, roll }, any subset. |
fov |
number | 5..170 degrees. Omit, or 0, to leave the engine's field of view alone. |
Open77.camera.setTransform(camId, position?, rotation?)
Open77.camera.setTransform(camId, { position =, rotation =, fov = })
Moves and/or turns a camera. Cheap enough to call every frame β that is how you
write a dolly. Setting a rotation clears a previous lookAt: an explicit
rotation is an explicit aim, and it would be a trap to accept the call and let a
stale look-at quietly override it.
Open77.camera.lookAt(camId, position | entity)
Point the camera at a world point or at an entity. Entity 0 is the local
player. The aim is re-solved every frame, so a tracked entity that moves
stays centred. An entity that streams out holds the last aim rather than
snapping the camera to the world origin.
Open77.camera.attach(camId, entity, offset?)
Parent the camera to an entity: it follows the parent's position and rotation,
so attach plus a fixed rotation is a dealership turntable. Entity 0 is the
local player.
Open77.camera.attach() with no arguments is the older call that restores
the player's own camera, and it still means exactly that.
Open77.camera.detachFrom(camId)
The opposite of the scripted attach. Not spelled detach, because
Open77.camera.detach() already means "move the player's own camera off their
eyes" and all of its arguments are optional, so there is no arity left to
overload. The camera keeps the pose it had; your next setTransform places it.
Open77.camera.activate(camId, { blendMs }) -> true, promise | false, reason
Take the view. blendMs interpolates from wherever the view currently is; omit
it, or pass 0, for a cut. The second return value is an
Open77.Promise that resolves when the blend
completes and rejects with a reason if the shot is released first. A cut settles
before the call returns and therefore hands back no promise β there is nothing
left to wait for.
Open77.camera.deactivate({ blendMs }) -> true, promise | false, reason
Give the view back, blending to the player's own eyes. Only the holder may.
Open77.camera.destroy(camId)
Destroys a camera. If it is the live one, the view comes back.
Open77.camera.shake(camId?, preset, amplitude?, ms?)
Presets: hand, drunk, explosion, earthquake. amplitude defaults to
1.0 and is capped at 4.0; ms defaults to 500. Additive on top of the
resolved pose, so it composes with a blend and with a look-at. Omit camId to
shake the camera you are holding.
Open77.camera.stopShake(camId)
Open77.camera.cameras() -> table
Your cameras and whether you hold the view:
{ held = true, holder = "open77_creator", phase = "holding",
activeCamera = 1, blendRemainingMs = 0,
cameras = { { id = 1, active = true, attached = false, aim = "entity",
fov = 45.0, shaking = false, shakePreset = "hand",
shakeRemainingMs = 0, position = { x = β¦, y = β¦, z = β¦ } } } }
holder names whoever has the view, including somebody else β it is the one
question a camera_held_by: refusal leaves you asking. phase is idle,
blending_in, holding or blending_out.
Open77.camera.unproject(x, y) -> { origin, direction }
The world ray behind a screen point. x and y are 0..1 with the origin at
the top left β exactly the frame Open77.camera.project returns, so the two
round-trip:
local screen = Open77.camera.project(worldPoint)
local ray = Open77.camera.unproject(screen.x, screen.y)
-- ray.direction now points from ray.origin back at worldPoint
Ungated, like project and view: it reads the camera, it does not move it.
Open77.camera.follow(entity, options?) -> camId, promise | nil, reason
Rides an entity: the camera sits behind it, aims at it, and both are re-solved
every frame, so the shot stays put while the target walks, turns, drives or
falls over. Entity 0 is the local player's own body.
local cam = assert(Open77.camera.follow(targetEntity, {
distance = 5.0, -- metres behind, 0..100 (default 4.0)
height = 2.0, -- metres above, -50..50 (default 1.5)
side = 0.0, -- metres right, -50..50 (default 0)
fov = 0, -- 0 leaves the field of view alone
blendMs = 400,
}))
distance, height and side are in the target's own frame, which is what
makes distance mean "behind him" rather than "north of him".
One camera per resource, reused. Calling follow again with a different
entity retargets the same camera and returns the same id; it does not create a
second one. That is deliberate β a resource that follows the nearest player once
a second would otherwise exhaust its sixteen-camera budget in sixteen seconds
and start failing for a reason that has nothing to do with what it asked for. It
also means each call supersedes the previous call's blend promise, which then
settles as camera_superseded.
Open77.camera.unfollow({ blendMs }) gives the view back and destroys the
camera. A second call answers false, "not_following".
Nothing here is a new way to hold the view: follow is create + lookAt +
activate, so every release rule on this page applies to it unchanged.
The streaming ceiling applies and is not hidden. The world streams around
the player's body, not around the lens. Following a target three hundred
metres away gives you a correctly-placed camera looking at unstreamed space β
an empty frame, not an error. Move the body to the target first; the
server-side Open77.players.spectate shows the pattern.
Unchanged calls
detach, attach(), thirdPerson, orbit, clearOrbit, setFov(degrees),
view, project and aimRay keep their exact previous meaning and their
previous permissions. orbit / clearOrbit stay behind camera.preview.
Why camera.script and not camera.preview
camera.preview grants a yaw offset inside the third-person rig. It cannot
move the view off the player and it cannot take the view away from gameplay,
which is why a clothing shop can be given it without a second thought.
A scripted camera can put the view anywhere, look through a wall, and leave a player unable to see their own body. Those are different powers, and an operator has to be able to grant one without the other.
Failure tokens
| Token | Meaning |
|---|---|
permission_denied:camera.script |
the manifest does not declare the permission |
camera_unavailable_on_this_host |
the server-side runtime, or a build without the native |
invalid_camera_options |
create was not given a table |
invalid_camera_position / invalid_camera_offset / invalid_camera_look_at |
not a readable vector, or not finite |
invalid_camera_rotation |
neither a quaternion nor Euler degrees |
invalid_camera_fov / invalid_argument |
field of view outside 5..170, or a non-finite number |
invalid_camera_id / camera_not_found |
no such camera |
camera_not_owned |
it belongs to another resource, or to a previous generation of yours |
camera_held / camera_held_by:<resource> |
another resource has the view |
camera_not_active |
you do not hold the view |
camera_budget_exhausted |
16 cameras per resource, 64 per client |
camera_unavailable |
the player is dead or unreadable, or a perspective transition is in flight |
invalid_blend_ms |
not a number in 0..60000 |
invalid_shake_preset / unsupported_shake_preset / invalid_shake_argument |
see shake |
empty_camera_transform |
setTransform was given nothing to change |
invalid_screen_point |
unproject was given a non-finite coordinate |
invalid_entity |
follow was given something that is not an entity id |
invalid_follow_distance / invalid_follow_height / invalid_follow_side |
outside the range in follow |
not_following |
unfollow with no follow camera |
camera_superseded |
the blend promise of a follow a later follow replaced |
Worked example: a character creator
This example blends between a face camera and a full-body camera, then restores player control.
-- open77_creator/client.lua
local face, body, holding
local function shots()
local me = Open77.character.position() -- the local player's feet
face = assert(Open77.camera.create({
position = vector3(me.x, me.y - 0.85, me.z + 1.70),
lookAt = 0, -- the player's own body
fov = 32,
}))
body = assert(Open77.camera.create({
position = vector3(me.x, me.y - 2.60, me.z + 1.10),
lookAt = vector3(me.x, me.y, me.z + 0.95),
fov = 48,
}))
end
local function close(reason)
if not holding then return end
holding = false
Open77.camera.deactivate({ blendMs = 450 })
if face then Open77.camera.destroy(face); face = nil end
if body then Open77.camera.destroy(body); body = nil end
Open77.ui.close('creator') -- your own UI, whatever it is
if reason then print('creator closed: ' .. reason) end
end
function OpenCreator()
shots()
local ok, blended = Open77.camera.activate(face, { blendMs = 700 })
if not ok then
-- `camera_held_by:<resource>` names who to blame; anything else is a
-- refusal we simply have to respect.
print('creator cannot take the camera: ' .. tostring(blended))
return false
end
holding = true
if blended then
local _, why = blended:await()
if why then close(why); return false end -- released before we got there
end
Open77.ui.open('creator')
return true
end
-- A cut to the body shot. Same owner, so it is allowed, and the face shot's
-- promise rejects with `camera_superseded` rather than hanging.
function ShowBody()
if not holding then return end
local ok, blended = Open77.camera.activate(body, { blendMs = 350 })
if ok and blended then blended:await() end
end
-- A slow turntable while the body shot is up.
local angle = 0.0
CreateThread(function()
while true do
Wait(16)
if holding and Open77.camera.cameras().activeCamera == body then
angle = (angle + 0.6) % 360.0
local me = Open77.character.position()
local radians = math.rad(angle)
Open77.camera.setTransform(body, vector3(
me.x + math.sin(radians) * 2.6,
me.y - math.cos(radians) * 2.6,
me.z + 1.10))
-- `lookAt` was set at create time and is re-solved every frame, so the
-- body stays centred as the camera swings.
end
end
end)
function CloseCreator() close(nil) end
-- Belt and braces. Every path below already releases the camera inside the
-- client; this only puts YOUR state back.
AddEventHandler('onClientResourceStop', function(name)
if name == Open77.resource.name() then close('resource_stopped') end
end)
Manifest:
resource 'open77_creator'
version '1.0.0'
client_scripts { 'client.lua' }
permissions { 'camera.script' }
Integration checklist
- Restore the player camera, input, HUD and FOV on completion, cancellation, errors and resource stop.
- Handle camera ownership refusals instead of overriding another resource's view.
- Keep targets inside the supported streaming range.
- For spectating, restore visibility and collision when either player dies, disconnects or changes bucket.
- Avoid simultaneous control by photo mode, vehicle cameras and scripted cameras.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
