Claude Skill

roblox-camera

Use when scripting Roblox camera behavior, CFrame placement, screen raycasts, first or third-person views, or cutscenes.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download tabooharmony-roblox-brain-skills_gameplay_roblox-camera-6051c35.zip · 8 KB
Part of tabooharmony/roblox-brain — 29 skills

Install

skills CLI npx skills add https://github.com/TabooHarmony/roblox-brain/tree/main/skills/gameplay/roblox-camera
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tabooharmony-roblox-brain@llmmart
Git git clone https://github.com/TabooHarmony/roblox-brain.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tabooharmony/roblox-brain collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Roblox Camera

When to Load

Load when scripting custom camera behavior (cutscenes, third-person follow, custom rotation), manipulating CFrame for placement/rotation, raycasting from the screen, or building a non-default player view. Client-only.

Quick Reference

The Camera: workspace.CurrentCamera: one per client. Set CameraType = Scriptable to disable defaults and take full control. Without it, defaults overwrite your CFrame every frame.

CameraType: Fixed, Attach/Watch/Track/Follow (subject-following), Custom (default), Scriptable (no default), Orbital (fixed Y, rotates around player). CameraSubject cannot be nil: setting it reverts.

Key properties: CFrame, CameraSubject, FieldOfView (deg), FieldOfViewMode (Vertical/Diagonal), NearPlaneZ, ViewportSize, HeadLocked, HeadScale, Focus.

CFrame essentials:

Operation Luau
Position only CFrame.new(position)
Look at target CFrame.lookAt(position, target, upVector)
XYZ rotation CFrame.Angles(rx, ry, rz)
Local to world cf * Vector3.new(0, 0, 10)
Interpolate cf:Lerp(goal, alpha)
Unit axes cf.LookVector, cf.RightVector, cf.UpVector

Custom camera loop: always PreRender, never Heartbeat:

camera.CameraType = Enum.CameraType.Scriptable
RunService.PreRender:Connect(function(dt)
    local desired = CFrame.lookAt(head.Position - offset, head.Position)
    camera.CFrame = camera.CFrame:Lerp(desired, math.min(dt * 10, 1))
end)

Raycasting from camera: camera:ScreenPointToRay(mx, my) (accounts for GUI inset) vs camera:ViewportPointToRay(mx, my) (raw, NO inset). ScreenPointToRay returns a unit Ray (1 stud); multiply Direction by length for the actual raycast.

Pitfalls:

  • Client-only. Server sets silently dropped.
  • Without Scriptable, defaults overwrite your CFrame every frame.
  • PreRender for camera (visual sync). Heartbeat adds 1-frame lag. RenderStepped still works but is superseded by PreRender.
  • Camera.CFrame lacks VR head rotation; use GetRenderCFrame() for the true view.
  • SetRoll is outdated; apply roll via CFrame.Angles(0, 0, roll) on CFrame.
  • CameraSubject = nil reverts to previous.
  • CFrame.new(pos, lookAt) is legacy (back-compat only); use CFrame.lookAt(at, lookAt) for new code.
  • ScreenPointToRay ≠ ViewportPointToRay (GUI inset). Use ScreenPointToRay for mouse input.

See references/full.md for first/third-person recipes, cutscenes, screen shake, mouse-look, full API.

Files (roblox-brain)
  • references
    • full.md 20.9 KB
      # Roblox Camera: Full Reference
      
      
      > **Code in this reference is illustrative. Adapt to your game and verify in Studio before production use.**
      
      Camera work is **client-only**. `workspace.CurrentCamera`, `CFrame` setters, and `PreRender` callbacks must run in a `LocalScript` or `Script` with `RunContext = Client`. Server scripts silently drop camera writes.
      
      ## The Camera Object
      
      Each client has exactly one camera, accessible at `workspace.CurrentCamera`. Other `Camera` instances exist for `ViewportFrame`s (mini-map, security camera, etc.) but `CurrentCamera` is the player's main view.
      
      ### CameraType Enum
      
      | Value | Behavior | Requires CameraSubject? |
      |-------|----------|------------------------|
      | `Fixed` (0) | Stationary. | No |
      | `Attach` (1) | Moves with subject at a fixed offset; rotates as subject rotates. | Yes |
      | `Watch` (2) | Stationary, rotates to keep subject centered. | Yes |
      | `Track` (3) | Moves with subject's position, doesn't auto-rotate. | Yes |
      | `Follow` (4) | Moves with subject + rotates to keep centered. | Yes |
      | `Custom` (5) | Default mode used by Roblox core scripts. | No |
      | `Scriptable` (6) | No default behavior. You set `CFrame` manually. | No |
      | `Orbital` (7) | Fixed Y position, rotates around player. | Yes |
      
      `Attach`, `Watch`, `Track`, `Follow`, and `Orbital` all require a valid `CameraSubject`. Without one, behavior is undefined.
      
      ### CameraSubject
      
      Set to the local `Humanoid` by default for character following (with `Humanoid.CameraOffset` applied). Can also be set to a `BasePart` (e.g. a `VehicleSeat`).
      
      ```luau
      -- Restore default
      camera.CameraSubject = player.Character:FindFirstChildWhichIsA("Humanoid")
      ```
      
      `CameraSubject = nil` reverts to the previous value (you cannot set it to nil).
      
      ## Camera Properties (full)
      
      | Property | Type | Notes |
      |----------|------|-------|
      | `CFrame` | CFrame | Position + orientation. Defaults overwrite this unless `Scriptable`. |
      | `CameraSubject` | Instance | Humanoid, BasePart, etc. |
      | `CameraType` | CameraType | See enum above. |
      | `FieldOfView` | float (degrees) | 1–120. Vertical FOV when `FieldOfViewMode = Vertical`. |
      | `FieldOfViewMode` | Enum.FieldOfViewMode | `Vertical` or `Diagonal`. |
      | `DiagonalFieldOfView` | float (degrees) | Read when `FieldOfViewMode = Diagonal`. |
      | `MaxAxisFieldOfView` | float (degrees) | Read when `FieldOfViewMode = Diagonal`. |
      | `NearPlaneZ` | float (studs) | Near clipping plane. |
      | `ViewportSize` | Vector2 | Pixel size of the viewport. |
      | `HeadLocked` | bool | VR: locks view to head. |
      | `HeadScale` | float | VR: scales head CFrame. |
      | `Focus` | CFrame | CFrame the camera points at (managed by CameraType logic, not by you). |
      | `VRTiltAndRollEnabled` | bool | VR-only. |
      | `CoordinateFrame` | CFrame | Hidden alias for `CFrame`. Use `CFrame`. |
      
      ## Camera Methods (full)
      
      | Method | Returns | Use |
      |--------|---------|-----|
      | `GetRenderCFrame()` | CFrame | The "true" rendered CFrame, including VR head rotation not reflected in `.CFrame`. |
      | `GetRoll()` | float (radians) | Roll set via `SetRoll` (not roll manually applied via CFrame). |
      | `SetRoll(rollAngle)` | () | **Outdated**: apply roll via `CFrame.Angles(0, 0, roll)` on CFrame instead. |
      | `ScreenPointToRay(x, y, depth?)` | Ray | Unit ray from screen pixel coords. Accounts for GUI inset. |
      | `ViewportPointToRay(x, y, depth?)` | Ray | Unit ray from device-safe viewport coords. Does NOT account for GUI inset. |
      | `WorldToScreenPoint(worldPos)` | Vector3 | `(x, y, onScreen)`. Pixels accounting for GUI inset. |
      | `WorldToViewportPoint(worldPos)` | Vector3 | `(x, y, onScreen)` in device-safe viewport coords. |
      | `GetPartsObscuringTarget(castPoints, ignoreList)` | {BasePart} | Parts obscuring the camera's view of given world points. |
      | `ZoomToExtents()`, `Interpolate(...)` | () | Editor camera methods, not for gameplay cameras. |
      | `PanUnits`, `TiltUnits`, `GetPanSpeed`, `GetTiltSpeed` | various | Editor viewport camera controls. |
      
      ### Event
      
      `InterpolationFinished`: fires when `Interpolate` completes.
      
      ## ScreenPointToRay vs ViewportPointToRay
      
      The two are NOT interchangeable:
      
      | Method | Coordinates | GUI inset? | Use when |
      |--------|-------------|-----------|----------|
      | `ScreenPointToRay(x, y)` | Pixel coords, origin = top-left **below** top bar | Yes | Mouse picking, player-facing UI clicks |
      | `ViewportPointToRay(x, y)` | Device-safe pixel coords, origin = top-left of Roblox top bar | No | Raw device input, drawing in viewport space |
      
      Both return a **unit Ray** (1 stud long). To get an actual raycast, multiply `Direction`:
      
      ```luau
      local unitRay = camera:ScreenPointToRay(mouseX, mouseY)
      local result = workspace:Raycast(unitRay.Origin, unitRay.Direction * 500)
      ```
      
      `ScreenPointToRay` only works for `workspace.CurrentCamera`. ViewportFrame cameras have an initial `(1,1)` viewport size that doesn't update until you set them as CurrentCamera.
      
      ## CFrame Math
      
      ### Constructors
      
      | Constructor | Notes |
      |-------------|-------|
      | `CFrame.new()` | Identity at origin. |
      | `CFrame.new(pos: Vector3)` | Position only, identity rotation. |
      | `CFrame.new(x, y, z)` | Position only. |
      | `CFrame.new(x, y, z, qX, qY, qZ, qW)` | Position + quaternion rotation. |
      | `CFrame.new(x, y, z, R00, R01, ..., R22)` | Position + raw 3x3 rotation matrix. |
      | `CFrame.new(pos, lookAt)` | **Legacy** (back-compat); use `CFrame.lookAt`. |
      | `CFrame.lookAt(at, lookAt, up?)` | Construct at `at` oriented toward `lookAt`. Optional `up` defaults to `(0,1,0)`. Fails if `lookAt` directly above `at` (use `lookAlong`). |
      | `CFrame.lookAlong(at, direction, up?)` | Construct at `at` oriented along `direction`. |
      | `CFrame.fromRotationBetweenVectors(from, to)` | Rotation that maps `from` to `to`. |
      | `CFrame.fromEulerAngles(rx, ry, rz, order?)` | Euler angles (radians), default XYZ. |
      | `CFrame.fromEulerAnglesXYZ(rx, ry, rz)` | XYZ order (== `CFrame.Angles`). |
      | `CFrame.fromEulerAnglesYXZ(rx, ry, rz)` | YXZ order. |
      | `CFrame.Angles(rx, ry, rz)` | XYZ Euler, shorthand. |
      | `CFrame.fromOrientation(rx, ry, rz)` | YXZ Euler, shorthand. |
      | `CFrame.fromAxisAngle(axis: Vector3, angle: number)` | Rotation around an arbitrary axis. |
      | `CFrame.fromMatrix(pos, xVector, yVector, zVector)` | From explicit basis vectors. |
      
      ### Properties
      
      | Property | Type | Notes |
      |----------|------|-------|
      | `Position` | Vector3 | Position component. |
      | `Rotation` | Vector3 | Euler angles (radians). |
      | `X`, `Y`, `Z` | number | Components of `Position`. |
      | `LookVector` | Vector3 | -Z axis in world space. Forward. |
      | `RightVector` | Vector3 | X axis in world space. |
      | `UpVector` | Vector3 | Y axis in world space. |
      | `XVector`, `YVector`, `ZVector` | Vector3 | Same as Right/Up/-LookVector. |
      
      ### Methods
      
      | Method | Returns | Use |
      |--------|---------|-----|
      | `cf:Inverse()` | CFrame | Inverse. |
      | `cf:Lerp(goal, alpha)` | CFrame | Linear interp 0..1. |
      | `cf:Orthonormalize()` | CFrame | Renormalize (fix drift from many small ops). |
      | `cf:ToWorldSpace(...cfs)` | CFrame(s) | `= cf * other`. |
      | `cf:ToObjectSpace(worldCf)` | CFrame | `= cf:Inverse() * worldCf`. |
      | `cf:PointToWorldSpace(p)` | Vector3 | Treats `p` as position. |
      | `cf:PointToObjectSpace(p)` | Vector3 | Inverse for positions. |
      | `cf:VectorToWorldSpace(v)` | Vector3 | Treats `v` as direction (no translation). |
      | `cf:VectorToObjectSpace(v)` | Vector3 | Inverse for directions. |
      | `cf:GetComponents()` | (number×12) | Flatten to 12 numbers. |
      | `cf:ToEulerAngles()` | (rx, ry, rz) | XYZ Euler. |
      | `cf:ToEulerAnglesXYZ()` | (rx, ry, rz) | XYZ Euler. |
      | `cf:ToEulerAnglesYXZ()` | (rx, ry, rz) | YXZ Euler. |
      | `cf:ToOrientation()` | (rx, ry, rz) | YXZ Euler. |
      | `cf:ToAxisAngle()` | (axis, angle) | Decompose into axis-angle. |
      | `cf:FuzzyEq(other, epsilon?)` | bool | Approximate equality. |
      | `cf:AngleBetween(other)` | float | Angle between two CFrame orientations. |
      
      `BasePart.CFrame` is auto-orthonormalized, but `CFrame` math operations can drift; call `:Orthonormalize()` before passing to APIs that don't auto-normalize.
      
      ### Math Operations
      
      | Op | Type | Returns |
      |----|------|---------|
      | `cf + cf` | CFrame | Sum of components (rarely the right choice). |
      | `cf - cf` | CFrame | Difference. |
      | `cf * cf` | CFrame | Composition: rotate/translate by right operand in left's space. |
      | `cf * Vector3` | Vector3 | Point: position transformed. |
      | `cf / cf` | CFrame | Right-multiplied by inverse. |
      
      ## Custom Camera Controllers
      
      ### Smooth follow (third person)
      
      ```luau
      local RunService = game:GetService("RunService")
      local Players = game:GetService("Players")
      
      local player = Players.LocalPlayer
      local camera = workspace.CurrentCamera
      
      local OFFSET = Vector3.new(0, 4, 12)
      local SMOOTH = 8
      
      camera.CameraType = Enum.CameraType.Scriptable
      
      RunService.PreRender:Connect(function(dt)
          local character = player.Character
          if not character then return end
          local root = character:FindFirstChild("HumanoidRootPart")
          if not root then return end
      
          local target = root.CFrame * CFrame.new(0, 2, 0)
          local desired = CFrame.lookAt(target.Position + OFFSET, target.Position)
      
          local alpha = math.min(dt * SMOOTH, 1)
          camera.CFrame = camera.CFrame:Lerp(desired, alpha)
      end)
      ```
      
      ### First-person
      
      ```luau
      camera.CameraType = Enum.CameraType.Scriptable
      
      RunService.PreRender:Connect(function()
          local head = character:FindFirstChild("Head")
          if not head then return end
          camera.CFrame = head.CFrame + Vector3.new(0, 0.5, 0)
      end)
      ```
      
      For real first-person you also want to hide the local head: set the head's `LocalTransparencyModifier = 1` (client-only) on the local character, or render with a `FirstPersonAccessory` rig.
      
      ### Third-person with adjustable zoom (mouse wheel)
      
      ```luau
      local zoomDist = 12
      local MIN_ZOOM, MAX_ZOOM = 2, 30
      
      UIS.InputChanged:Connect(function(input, gpe)
          if gpe then return end
          if input.UserInputType == Enum.UserInputType.MouseWheel then
              zoomDist = math.clamp(zoomDist - input.Position.Z * 2, MIN_ZOOM, MAX_ZOOM)
          end
      end)
      ```
      
      ### Orbital camera (fixed Y, mouse-look)
      
      ```luau
      local yaw, pitch = 0, 0
      local DIST = 12
      local PITCH_LIMIT = math.rad(80)
      
      UIS.InputChanged:Connect(function(input, gpe)
          if gpe then return end
          if input.UserInputType == Enum.UserInputType.MouseMovement then
              yaw   = yaw   - input.Delta.X * 0.003
              pitch = math.clamp(pitch - input.Delta.Y * 0.003, -PITCH_LIMIT, PITCH_LIMIT)
          end
      end)
      
      RunService.PreRender:Connect(function(_dt)
          local root = character.HumanoidRootPart
          local orbit = CFrame.Angles(0, yaw, 0) * CFrame.Angles(pitch, 0, 0)
          local offset = orbit.LookVector * DIST
          camera.CFrame = CFrame.lookAt(root.Position + offset, root.Position)
      end)
      ```
      
      ### Mouse-look (FPS)
      
      ```luau
      local ROT_SPEED = 0.003
      local PITCH_LIMIT = math.rad(89)
      local yaw, pitch = 0, 0
      
      UIS.InputBegan:Connect(function(input, _gpe)
          if input.UserInputType == Enum.UserInputType.MouseButton2 then
              UIS.MouseBehavior = Enum.MouseBehavior.LockCenter
          end
      end)
      UIS.InputEnded:Connect(function(input)
          if input.UserInputType == Enum.UserInputType.MouseButton2 then
              UIS.MouseBehavior = Enum.MouseBehavior.Default
          end
      end)
      
      UIS.InputChanged:Connect(function(input, gpe)
          if gpe then return end
          if input.UserInputType == Enum.UserInputType.MouseMovement then
              yaw   = yaw   - input.Delta.X * ROT_SPEED
              pitch = math.clamp(pitch - input.Delta.Y * ROT_SPEED, -PITCH_LIMIT, PITCH_LIMIT)
          end
      end)
      
      RunService.PreRender:Connect(function()
          if not character:FindFirstChild("Head") then return end
          local head = character.Head
          camera.CFrame = CFrame.new(head.Position) * CFrame.Angles(0, yaw, 0) * CFrame.Angles(pitch, 0, 0)
      end)
      ```
      
      ## Cutscenes
      
      ### Basic tween
      
      ```luau
      local TweenService = game:GetService("TweenService")
      local camera = workspace.CurrentCamera
      
      -- Snapshot the current mode; restoration must put back what the player had.
      local prevCameraType = camera.CameraType
      camera.CameraType = Enum.CameraType.Scriptable
      
      local tweenInfo = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut)
      local targetCFrame = CFrame.lookAt(Vector3.new(0, 20, 30), Vector3.new(0, 5, 0))
      
      local tween = TweenService:Create(camera, tweenInfo, {CFrame = targetCFrame})
      tween:Play()
      tween.Completed:Wait()
      
      -- Restore the mode the player actually had, not a hardcoded default.
      camera.CameraType = prevCameraType
      ```
      
      ### Multi-shot cutscene with input lock
      
      ```luau
      local cutsceneId = 0 -- ownership token: a newer cutscene supersedes older ones
      -- The snapshot of the state this cutscene locks. At most one cutscene owns
      -- it: a newer run ADOPTS the pending snapshot instead of re-snapshotting
      -- values it already finds locked to zero.
      local cutsceneSnapshot: {
          cameraType: Enum.CameraType,
          walkSpeed: number,
          jumpPower: number,
      }? = nil
      
      local function playCutscene(cameraPath: {CFrame}, duration: number)
          cutsceneId += 1
          local myId = cutsceneId
      
          -- Adopt the prior run's snapshot if one is pending; only the FIRST
          -- cutscene reads the player's live values. A replacement re-snapshot
          -- would capture our own locked zeros and restore them as if they were
          -- the player's real state.
          local saved = cutsceneSnapshot
          -- Function scope: both the snapshot branch and the lock/restore below
          -- need the humanoid, so the lookup must not live inside the conditional.
          local humanoid = character:FindFirstChildWhichIsA("Humanoid")
          if not saved then
              saved = {
                  cameraType = camera.CameraType,
                  walkSpeed = humanoid.WalkSpeed,
                  jumpPower = humanoid.JumpPower,
              }
              cutsceneSnapshot = saved
          end
      
          camera.CameraType = Enum.CameraType.Scriptable
          humanoid.WalkSpeed = 0
          humanoid.JumpPower = 0
      
          for _, target in ipairs(cameraPath) do
              local info = TweenInfo.new(duration / #cameraPath, Enum.EasingStyle.Sine)
              local tween = TweenService:Create(camera, info, {CFrame = target})
              tween:Play()
              tween.Completed:Wait() -- also resumes if a newer tween cancels this one
              if cutsceneId ~= myId then
                  return -- superseded: the newer cutscene owns camera, movement,
                        -- AND the pending snapshot; leave it pending for that run
              end
          end
      
          -- Restore the snapshot, never hardcoded defaults. Only the newest run
          -- reaches here (older runs returned early), and the snapshot still
          -- pending is the original player state.
          humanoid.WalkSpeed = saved.walkSpeed
          humanoid.JumpPower = saved.jumpPower
          camera.CameraType = saved.cameraType
          cutsceneSnapshot = nil -- state is restored: nothing pending
      end
      ```
      
      Overlapping runs are safe by construction: the first run snapshots the live
      values; every superseding run reuses that same snapshot; only the run that
      finishes last (the current owner) restores it and clears the pending slot.
      
      ### Screen shake
      
      ```luau
      local shakeToken = 0 -- ownership token: only the newest shake drives state
      local shakeIntensity = 0
      local prevOffset = CFrame.identity
      local lastShakeCFrame: CFrame? = nil -- camera CFrame as our last shake write left it
      
      local function startShake(intensity: number, duration: number)
          shakeToken += 1
          local myToken = shakeToken
          shakeIntensity = intensity
      
          task.spawn(function()
              local t = 0
              while t < duration do
                  t += task.wait()
                  if myToken ~= shakeToken then return end -- a newer shake owns state
                  shakeIntensity = intensity * (1 - t / duration)
              end
              if myToken ~= shakeToken then return end
              shakeIntensity = 0
          end)
      end
      
      RunService.PreRender:Connect(function()
          if shakeIntensity <= 0 then
              -- Strip the final offset exactly once so it does not stay baked in.
              -- Only strip when the camera STILL holds our last write: another
              -- system may have overwritten camera.CFrame since, and stripping
              -- from that fresh base would subtract an offset it never contained.
              if prevOffset ~= CFrame.identity then
                  if lastShakeCFrame and camera.CFrame == lastShakeCFrame then
                      camera.CFrame = camera.CFrame * prevOffset:Inverse()
                  end
                  prevOffset = CFrame.identity
                  lastShakeCFrame = nil
              end
              return
          end
          local offset = CFrame.new(
              (math.random() - 0.5) * shakeIntensity,
              (math.random() - 0.5) * shakeIntensity,
              (math.random() - 0.5) * shakeIntensity
          )
          -- Re-base on the CURRENT camera CFrame each frame, and remember it: strip
          -- the previous frame's offset only while our last write is still current.
          -- If another system moved the camera between frames, that fresh base never
          -- contained our offset, so subtracting it would corrupt their CFrame;
          -- instead the stale offset is simply dropped (resets shake baseline).
          local base = camera.CFrame
          if lastShakeCFrame and base == lastShakeCFrame then
              base = base * prevOffset:Inverse() -- our offset is still baked in
          end
          camera.CFrame = base * offset
          lastShakeCFrame = camera.CFrame
          prevOffset = offset
      end)
      ```
      
      ## World ↔ Screen Conversion
      
      ```luau
      -- World to screen (with GUI inset)
      local screenPos, onScreen = camera:WorldToScreenPoint(worldPos)
      -- screenPos.X, screenPos.Y are pixel coordinates (accounting for GUI inset)
      -- onScreen = true if point is within the visible viewport
      -- screenPos.Z > 0 means in front of camera
      
      -- World to viewport (raw device coords)
      local viewportPos, onScreen = camera:WorldToViewportPoint(worldPos)
      ```
      
      ## ViewportFrame Portals (seamless portals)
      
      Community technique (DevForum "Making seamless portals - Tutorial", thiagop123, 2026, https://devforum.roblox.com/t/making-seamless-portals-tutorial/4731945) for rendering a live portal to another location: clone the destination world Model into a `ViewportFrame` whose camera is a second `Camera` instance, and display it on the portal's face. Crossing is handled by raycasting the `HumanoidRootPart`'s movement against the portal part each frame; when the character crosses the front face (`dot ≥ 0.999`), teleport it and remap position, direction, and velocity relative to the destination surface (180° mirrored); directionality replaces a cooldown. A per-frame "physics hole" (collision disabled within ~5 studs) lets the character pass through the opening without falling off the edges; a `CollisionGroups` server script handles portal-air collision. Verify performance: each pair costs 2 world clones + 2 clones per character + per-frame render (see the thread's common-problems table for StreamingEnabled, back-face entry, and camera-flicker/`BindToRenderStep` pitfalls).
      
      ## Mouse-to-World Raycast Pattern
      
      ```luau
      local function mouseHit(mouseX, mouseY, maxDist)
          local unitRay = camera:ScreenPointToRay(mouseX, mouseY)
          return workspace:Raycast(unitRay.Origin, unitRay.Direction * maxDist)
      end
      
      local result = mouseHit(mouse.X, mouse.Y, 1000)
      if result then
          local hitPart = result.Instance
          local hitPos = result.Position
          local hitNormal = result.Normal
          -- ...
      end
      ```
      
      ## Camera in Studio MCP
      
      When scripting a camera via `execute_luau` in Studio (Play mode), set the camera on the active client. Useful for testing cutscenes:
      
      ```luau
      -- In execute_luau, the local script context is the editing client
      local camera = workspace.CurrentCamera
      camera.CameraType = Enum.CameraType.Scriptable
      camera.CFrame = CFrame.lookAt(Vector3.new(0, 50, 50), Vector3.zero)
      ```
      
      For non-Play testing (Edit mode), `CurrentCamera` exists but the perspective doesn't render the way gameplay does; Scriptable camera changes won't be visible until Play.
      
      ## Common Mistakes
      
      - **Camera work on the server.** Silently dropped. Must be in `LocalScript` or client `RunContext`.
      - **Forgetting `CameraType = Scriptable`.** Defaults overwrite your CFrame every frame.
      - **Using `Heartbeat` instead of `PreRender` for camera.** Adds 1 frame of lag. (`RenderStepped` still works but is superseded by `PreRender`.)
      - **Setting `CameraSubject = nil` "expecting it to detach".** It reverts to previous value.
      - **Reading `Camera.CFrame` in VR for gameplay math.** Use `GetRenderCFrame()` for true view (includes head rotation).
      - **Using `SetRoll` because it's convenient.** It's outdated; apply roll via `CFrame.Angles(0, 0, roll)` on CFrame.
      - **`CFrame.new(pos, lookAt)` because old tutorials say so.** Legacy/back-compat. Use `CFrame.lookAt(at, lookAt)`.
      - **Confusing `ScreenPointToRay` with `ViewportPointToRay`.** GUI inset handling differs. Use `ScreenPointToRay` for mouse input.
      - **Forgetting to restore `CameraType` after a cutscene.** Player is stuck.
      - **Trying to `Raycast` with the unit Ray directly.** It's only 1 stud long. Multiply `Direction` by the actual distance.
      - **Modifying the local head in first-person without `LocalTransparencyModifier`.** Players see their own head blocking the view. Use `LocalTransparencyModifier = 1`, not `Transparency` (Transparency replicates).
      - **Setting `Humanoid.JumpPower = 0` to disable jump in a cutscene.** Remember to restore it. Or use `Humanoid:SetStateEnabled(Enum.HumanoidStateType.Jumping, false)`.
      - **Trying to set Camera.CFrame every frame without clearing the render connection on player respawn.** Stale references after respawn. Reconnect in `CharacterAdded`.
      
  • SKILL.md 2.9 KB
    ---
    name: roblox-camera
    description: "Use when scripting Roblox camera behavior, CFrame placement, screen raycasts, first or third-person views, or cutscenes."
    last_reviewed: 2026-06-29
    sources:
      - https://create.roblox.com/docs/reference/engine/classes/Camera
      - https://create.roblox.com/docs/reference/engine/enums/CameraType
      - https://create.roblox.com/docs/reference/engine/datatypes/CFrame
    ---
    
    # Roblox Camera
    
    ## When to Load
    
    Load when scripting custom camera behavior (cutscenes, third-person follow, custom rotation), manipulating `CFrame` for placement/rotation, raycasting from the screen, or building a non-default player view. Client-only.
    
    ## Quick Reference
    
    **The Camera**: `workspace.CurrentCamera`: one per client. Set `CameraType = Scriptable` to disable defaults and take full control. Without it, defaults overwrite your CFrame every frame.
    
    **CameraType**: `Fixed`, `Attach`/`Watch`/`Track`/`Follow` (subject-following), `Custom` (default), `Scriptable` (no default), `Orbital` (fixed Y, rotates around player). `CameraSubject` cannot be `nil`: setting it reverts.
    
    **Key properties**: `CFrame`, `CameraSubject`, `FieldOfView` (deg), `FieldOfViewMode` (`Vertical`/`Diagonal`), `NearPlaneZ`, `ViewportSize`, `HeadLocked`, `HeadScale`, `Focus`.
    
    **CFrame essentials**:
    
    | Operation | Luau |
    | --- | --- |
    | Position only | `CFrame.new(position)` |
    | Look at target | `CFrame.lookAt(position, target, upVector)` |
    | XYZ rotation | `CFrame.Angles(rx, ry, rz)` |
    | Local to world | `cf * Vector3.new(0, 0, 10)` |
    | Interpolate | `cf:Lerp(goal, alpha)` |
    | Unit axes | `cf.LookVector`, `cf.RightVector`, `cf.UpVector` |
    
    **Custom camera loop**: always `PreRender`, never `Heartbeat`:
    
    ```luau
    camera.CameraType = Enum.CameraType.Scriptable
    RunService.PreRender:Connect(function(dt)
        local desired = CFrame.lookAt(head.Position - offset, head.Position)
        camera.CFrame = camera.CFrame:Lerp(desired, math.min(dt * 10, 1))
    end)
    ```
    
    **Raycasting from camera**: `camera:ScreenPointToRay(mx, my)` (accounts for GUI inset) vs `camera:ViewportPointToRay(mx, my)` (raw, NO inset). `ScreenPointToRay` returns a unit Ray (1 stud); multiply `Direction` by length for the actual raycast.
    
    **Pitfalls**:
    - Client-only. Server sets silently dropped.
    - Without `Scriptable`, defaults overwrite your CFrame every frame.
    - `PreRender` for camera (visual sync). `Heartbeat` adds 1-frame lag. `RenderStepped` still works but is superseded by `PreRender`.
    - `Camera.CFrame` lacks VR head rotation; use `GetRenderCFrame()` for the true view.
    - `SetRoll` is outdated; apply roll via `CFrame.Angles(0, 0, roll)` on CFrame.
    - `CameraSubject = nil` reverts to previous.
    - `CFrame.new(pos, lookAt)` is legacy (back-compat only); use `CFrame.lookAt(at, lookAt)` for new code.
    - `ScreenPointToRay` ≠ `ViewportPointToRay` (GUI inset). Use `ScreenPointToRay` for mouse input.
    
    See `references/full.md` for first/third-person recipes, cutscenes, screen shake, mouse-look, full API.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related