About camera-control
camera-control is a camera guide for Decentraland SDK7 developers. It covers reading camera state, reacting to view modes, and controlling views with camera areas or virtual cameras, including cinematic shots, target tracking, and handoff caveats.
Key features
- 01Read camera position and rotation
- 02Detect and react to first- or third-person mode
- 03Restrict camera mode with an area
- 04Create virtual cameras with transitions
- 05Track a target with a virtual camera
Use cases
- 01Force first-person view in indoor areas
- 02Switch views for a cutscene
- 03Create a camera that follows the player
- 04Track camera position to drive scene logic
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill camera-controlAfter installing, type/camera-controlin Claude Code to confirm it loaded.
Bundled filesreferences/camera-patterns.md
Details
From the project READMECamera Control in Decentraland
Reading Camera State
Access the camera's current position and rotation via the reserved engine.CameraEntity:
import { engine, Transform } from '@dcl/sdk/ecs'
function trackCamera() {
if (!Transform.has(engine.CameraEntity)) return
const cameraTransform = Transform.get(engine.CameraEntity)
console.log('Camera position:', cameraTransform.position)
console.log('Camera rotation:', cameraTransform.rotation)
}
engine.addSystem(trackCamera)
engine.CameraEntity is read-only — never write to its Transform. To move a camera under scene control, drive a VirtualCamera instead (see VirtualCamera below).
Camera Mode Detection
Check whether the player is in first-person or third-person:
import { engine, CameraMode, CameraType } from '@dcl/sdk/ecs'
function checkCameraMode() {
if (!CameraMode.has(engine.CameraEntity)) return
const cameraMode = CameraMode.get(engine.CameraEntity)
if (cameraMode.mode === CameraType.CT_FIRST_PERSON) {
console.log('First person camera')
} else if (cameraMode.mode === CameraType.CT_THIRD_PERSON) {
console.log('Third person camera')
}
}
engine.addSystem(checkCameraMode)
Camera Mode Values
CameraType.CT_FIRST_PERSON // First-person view
CameraType.CT_THIRD_PERSON // Third-person view (default)
React to Camera Mode Changes
Use CameraMode.onChange to get notified only when the player toggles between first and third person — cheaper than polling every frame:
import { CameraMode, engine } from '@dcl/sdk/ecs'
CameraMode.onChange(engine.CameraEntity, (camera) => {
if (!camera) return
// camera.mode is 0 for first person, 1 for third person
console.log('Camera mode changed:', camera.mode)
})
CameraModeArea (Force Camera in a Region)
Force a specific camera mode when the player enters an area (e.g. force first-person in tight indoor spaces):
import { engine, Transform, CameraModeArea, CameraType } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const fpArea = engine.addEntity()
Transform.create(fpArea, { position: Vector3.create(8, 1.5, 8) })
CameraModeArea.create(fpArea, {
area: Vector3.create(6, 4, 6), // 6x4x6 meter box
mode: CameraType.CT_FIRST_PERSON, // Force first-person inside
})
When the player leaves the area, the camera reverts to their preferred mode.
GOTCHA — a non-zero area wins over Transform.scale for the life of the component. Scaling the area entity, or any of its parents, after CameraModeArea.create() does not resize the region: it stays exactly area metres. An axis scaled to 0 still collapses it. So a camera region cannot be grown by tweening a scale or by parenting it under a rig that scales — write CameraModeArea.getMutable(entity).area instead (and mirror the numbers into scale so the Creator Hub gizmo agrees). Size a visible debug box as a separate entity; a MeshRenderer on the area entity itself misreports the volume as soon as either value changes. Full rule, plus the opposite behaviour of TriggerArea (no size field — the Transform is the volume, later scale changes included), in player-avatar → AvatarModifierArea. Regression coverage in 9,99-modifier-areas → src/areaScaleDrift.ts.
A whole-experience restriction needs whole-experience coverage. An area that covers only the spawn route proves nothing once the scene teleports, respawns, jumps the player to an alternate height, or hands the camera back after a cinematic. Size the area to the full horizontal and vertical play space (including debug jumps and death/restart positions), then test every transition in-world: spawn, teleport, death/restart, alternate height, unlock/relock and cinematic handback. When handing control back, restore the mode the scene intends (first person for a first-person scene), not a generic third person.
Creator Hub / Inspector support: the Creator Hub now has a dedicated inspector panel for CameraModeArea with a dropdown for mode (First Person / Third Person; Cinematic is intentionally omitted -- use VirtualCamera for that). A "Camera Modifier Area" smart item (utils category, translucent placeholder cube) is available in the asset catalog. The editor keeps the area field invisibly in sync with the entity's Transform.scale (the runtime sizes the region from a non-zero area and compensates out scale; a zero area falls back to the scale), so resizing the entity via the gizmo automatically updates the camera region. That mirroring is an editor-time write, not a runtime binding — at runtime area stands alone (GOTCHA above). Default init: { area: {1,1,1}, mode: CameraType.CT_FIRST_PERSON }. Verified against creator-hub commit a843390a. The same sizing rule as AvatarModifierArea in player-avatar applies: the explicit area sizes the region, and Transform.scale is kept mirrored for the editor. After place_smart_item, confirm with entity_detail that the entity carries core::CameraModeArea: the pack's composite.json defines it (asset-packs::Placeholder + core::CameraModeArea, creator-hub origin/main 2026-10-19), but one placement arrived without it (2026-10-08, Bevy editor, not reproduced) — if so, set_component it, sized to the room.
VirtualCamera (Cinematic Cameras)
Create scripted camera positions for cutscenes or special views:
import { engine, Transform, VirtualCamera, MainCamera } from '@dcl/sdk/ecs'
import { Vector3, Quaternion } from '@dcl/sdk/math'
const cinematicCam = engine.addEntity()
Transform.create(cinematicCam, {
position: Vector3.create(8, 5, 2),
rotation: Quaternion.fromEulerDegrees(-20, 0, 0),
})
VirtualCamera.create(cinematicCam, {
defaultTransition: {
transitionMode: VirtualCamera.Transition.Speed(1.0),
},
// fov: 45, // optional: field of view in degrees (overrides the Explorer default)
})
// Activate the virtual camera (createOrReplace on first activation:
// getMutable throws if MainCamera doesn't exist yet)
MainCamera.createOrReplace(engine.CameraEntity, { virtualCameraEntity: cinematicCam })
// Return to normal camera (component now exists, so getMutable is safe)
MainCamera.getMutable(engine.CameraEntity).virtualCameraEntity = undefined
Transition Modes
VirtualCamera.Transition.Speed(1.0) // Speed-based smooth transition
VirtualCamera.Transition.Time(2) // Time-based transition (2 seconds)
Keep transition speeds between 0.5 and 3.0 for comfortable camera movement.
Look-At Target
Make the virtual camera track an entity:
const target = engine.addEntity()
Transform.create(target, { position: Vector3.create(8, 1, 8) })
VirtualCamera.create(cinematicCam, {
lookAtEntity: target,
defaultTransition: {
transitionMode: VirtualCamera.Transition.Speed(2.0),
},
})
// Activate
MainCamera.createOrReplace(engine.CameraEntity, { virtualCameraEntity: cinematicCam })
lookAtEntity can be engine.PlayerEntity to keep the camera aimed at the moving player (verified: 2,22-virtual-cameras). To bake a fixed look direction into the camera's own rotation instead, use Quaternion.fromLookAt(cameraPosition, targetPosition) in the camera's Transform.
VirtualCamera.create(entity) with no config is valid (defaults) — useful for a camera whose Transform you drive manually (see the controllable-camera pattern below).
VirtualCamera accepts an optional fov?: number field (field of view in degrees). Defaults to 60 when omitted. When set, the virtual camera overrides the default FOV for as long as it is the active MainCamera.virtualCameraEntity. Verified against js-sdk-toolchain PBVirtualCamera commit 26cb9251 and docs commit 717227a.
Choosing a value:
- Wider (higher, e.g. 90) — shows more of the scene at once and creates a sense of speed. Good for racing and vehicle cameras.
- Narrower (lower, e.g. 35-45) — zooms in and flattens perspective. Good for aiming down sights and cinematic shots.
- Very wide values distort at the frame edges; keep a fixed framing camera near the default unless the distortion is the point.
Teardown order matters — clear the binding first, delete the entity later. Removing a
VirtualCameraentity in the same frame you stop using it makes the view jump, and removing it before clearingMainCamera.virtualCameraEntityleaves the engine bound to a dead entity, which drops the view to the player's feet.The safe sequence (verified in
33,20-spectate-mode):
- Set
MainCamera.virtualCameraEntity = undefined.- Leave the
VirtualCameraentity (and its rig parents) alive for ~1 second so the renderer can blend back to the player camera.- Then
engine.removeEntity(...)the rig.let rigDestroyTimer = 0 function rigDestroySystem(dt: number) { rigDestroyTimer += dt if (rigDestroyTimer < 1) return engine.removeSystem(rigDestroySystem) if (rigCamera) engine.removeEntity(rigCamera) if (rigRoot) engine.removeEntity(rigRoot) } // in disableSpectate(): clear MainCamera, restore input, then rigDestroyTimer = 0 engine.addSystem(rigDestroySystem)If the camera is reused rather than one-shot, simply keep the entity around and skip the delete.
The way back ignores
defaultTransition. Returning to the first-person, third-person or drone camera is always a fixed 0.5 s ease-in-out blend (the Explorer's camera blend settings, unity-explorer main 2026-10). From a shot far from the player that reads as a jump. To hand back smoothly, tween the virtual camera to the player camera's pose, then clearvirtualCameraEntityonce it arrives (a 0.9 s tween worked in the Locksmith scene, 2026-10):
- Read the pose from
Transform.get(engine.CameraEntity)just before the handback tween starts, not at shot start: if the avatar moved during the shot, an early pose is stale.- Tween the entity that carries the camera's world pose. On a parented rig like the spectate one, unparent the camera first. Otherwise tween the rig root so that the camera, not the root, ends on that pose. A tween on a child runs in its parent's local space, so a world pose fed to it lands somewhere else.
Switching between cameras / deactivating: with MainCamera already present, mutate it via MainCamera.getMutableOrNull(engine.CameraEntity); set virtualCameraEntity to another VirtualCamera entity to cut/transition to it, or to undefined to return to the player's normal camera (verified: 2,22-virtual-cameras). Only one VirtualCamera is active at a time (MainCamera.virtualCameraEntity holds a single entity).
Never write 0 — or any entity that has no VirtualCamera — into virtualCameraEntity. The engine releases the player camera only when the field is absent: undefined is omitted from the message, and MainCameraSystem then clears the binding. A present value whose entity carries no VirtualCamera (0 is engine.RootEntity) makes the engine retry every frame and keep the currently active virtual camera: if a rig was active, the view stays stuck on it even after input is restored (if none was active, nothing changes). This bites save/restore code: the field is optional, so a fresh MainCamera reads undefined — you only ever get 0 from a saved value scene code wrote, typically a ?? 0 / || 0 fallback — and writing that 0 back later sticks the camera (a capture sequence stuck this way, 2026-10; the mechanism is read from MainCameraSystem.HandleVirtualCameraChange on unity-explorer main, not reproduced in isolation). Treat both undefined and 0 as "no previous camera":
function restoreCamera(previous: Entity | undefined) {
const cam = MainCamera.getMutableOrNull(engine.CameraEntity)
if (!cam) return
if (previous && VirtualCamera.has(previous)) cam.virtualCameraEntity = previous
else cam.virtualCameraEntity = undefined // or MainCamera.deleteFrom(engine.CameraEntity) if the scene never bound one
}
Then verify the handback in-world (get_player_state → camera.mode back to a retail mode), including the interrupted and respawn paths — a headless test cannot see a stuck view.
You cannot move the player's real camera directly. To move a camera under scene control, drive the Transform of an active VirtualCamera entity each frame; while it is the MainCamera.virtualCameraEntity, the player sees through it (verified: 2,22-virtual-cameras controllable camera). Pair with InputModifier (advanced-input) to disable avatar movement so WASD drives the camera instead.
Tracking Camera Position
Poll camera position each frame by reading Transform.get(engine.CameraEntity).position inside a system. For a full worked zone-tracking system (cameraZoneSystem), see {baseDir}/references/camera-patterns.md → "Tracking Camera Position (camera zone system)".
## Camera and Colliders
When a player's camera moves in 3rd person mode, the camera might be blocked by colliders or not, depending on the collision layers assigned to the entities. To avoid the camera from going through walls, you must assign both the ColliderLayer.CL_PHYSICS and the ColliderLayer.CL_POINTER layers to the entities that you want to block the camera.
// NO CAMERA GOING THROUGH THE WALL
// default (both pointer and physics use the invisible geometry)
GltfContainer.create(myEntity, {
src: '/models/myModel.gltf',
})
// NO CAMERA GOING THROUGH THE WALL
// Both use the same invisible geometry
GltfContainer.create(myEntity2, {
src: '/models/myModel.gltf',
invisibleMeshesCollisionMask:
ColliderLayer.CL_PHYSICS | ColliderLayer.CL_POINTER,
})
// NO CAMERA GOING THROUGH THE WALL
// Both use the same visible geometry
GltfContainer.create(myEntity2, {
src: '/models/myModel.gltf',
visibleMeshesCollisionMask:
ColliderLayer.CL_PHYSICS | ColliderLayer.CL_POINTER,
})
// YES CAMERA GOES THROUGH THE WALL
// physics and pointer are on different layers
GltfContainer.create(myEntity2, {
src: '/models/myModel.gltf',
invisibleMeshesCollisionMask: ColliderLayer.CL_PHYSICS,
visibleMeshesCollisionMask: ColliderLayer.CL_POINTER,
})
// YES CAMERA GOES THROUGH THE WALL
// physics and pointer are on different layers
GltfContainer.create(myEntity2, {
src: '/models/myModel.gltf',
invisibleMeshesCollisionMask: ColliderLayer.CL_POINTER,
visibleMeshesCollisionMask: ColliderLayer.CL_PHYSICS,
})
Common Patterns
For full worked patterns, see {baseDir}/references/camera-patterns.md:
- Camera-Triggered Events — use camera position/proximity to trigger actions when the player looks at an area.
- Following an NPC (camera-follows-NPC) — track an NPC by driving a VirtualCamera's Transform each frame (guardrail on why this works lives in the VirtualCamera section above).
- Mouselook Camera (FPS-style) — drive a VirtualCamera with
PrimaryPointerInfo.screenDelta(pixel delta per frame, keeps working while cursor is locked). Accumulate into yaw/pitch, clamp pitch [-85,+85], combine with PointerLock + InputModifierdisableAll.screenDeltais top-left origin,ygrows downward, so pitch addsdelta.y(pitch + delta.y * SENS) — subtracting it inverts the vertical axis. Desktop only (screenDelta always 0 on mobile). See{baseDir}/references/camera-patterns.md→ "Mouselook Camera". - Spectate Mode (observer / director / replay camera) — toggle the player from avatar movement into a free-roaming or player-following camera: two-entity yaw/pitch rig, WASD/E/F/1/2 controls,
onEnterScene/onLeaveSceneplayer roster, InputModifier freeze, and parcel-bounds clamping (the engine disables VirtualCameras outside parcel bounds). See{baseDir}/references/camera-patterns.md→ "Spectate Mode".
Freezing player during cutscenes? Combine VirtualCamera with
InputModifierfrom the advanced-input skill to prevent player movement during cinematic sequences.
Example scenes
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/2,22-virtual-cameras — multiple VirtualCameras: static,
Speed/Timetransitions,lookAtEntity: engine.PlayerEntity, a Tween-driven moving camera, a WASD-controllable camera (driving the VirtualCamera Transform each frame), plusCameraModeAreaandAvatarModifierArea. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,5-primary-cursor-info — activating/deactivating VirtualCameras with
MainCameratoggled by key input, combined with InputModifier. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/32,20-virtual-camera-mouse-look — mouselook camera:
PrimaryPointerInfo.screenDeltadriving VirtualCamera yaw/pitch while pointer is locked, withInputModifierdisableAll and PointerLock control. Reference implementation for the mouselook pattern. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/33,20-spectate-mode — spectate mode: free-roaming / player-following camera in one self-contained module (
src/spectate.ts): two-entity yaw/pitch rig, player roster, WASD/E/F/1/2 controls, parcel-bounds clamping, the delayed-teardown sequence above, and fullTouchScreenControlsmobile parity (its README documents only the desktop bindings — the mobile handling is in the source). Reference implementation for the spectate pattern. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/9,99-modifier-areas —
CameraModeAreaforcingCameraType.CT_FIRST_PERSONinside a rotated 4x4x4 volume, rendered as a translucent box so the trigger volume is visible. A click handler moves the area'sTransformto show the area follows the entity; a commented-out branch shows mutatingmodein place viaCameraModeArea.getMutable(). Sits alongside anAvatarModifierAreain the same scene.