Roblox Camera Systems: Building Custom Third-Person and First-Person Controllers

The camera is the one system every player in your Roblox game touches for the entire session, and it is also the system most builders never think to write themselves. They ship the stock over-the-shoulder rig, notice the game feels generic, and blame the animations or the lighting instead.
A camera you build yourself is what separates a project that reads like a Studio template from one that feels authored. It costs a few hundred lines of Luau and buys you full ownership of how the world is framed, how the character turns, and how the lens reacts to walls, sprinting, and aiming.
A custom Roblox camera controller sets Camera.CameraType to Scriptable and rewrites Camera.CFrame every frame from RunService. That hands you full control over distance, orbit angle, wall collision, and field of view.
This guide walks the full build for both perspectives — a third-person orbit rig and a first-person controller — plus the collision handling and frame-budget discipline that keep them smooth. Everything here runs on the client in a LocalScript, because the camera is a client-owned system from the ground up.
How the Default Roblox Camera Works — and Why You Would Replace It
Roblox ships a capable default camera through the PlayerModule that lives in every player's PlayerScripts. It handles orbit, zoom, mouse-lock, and the standard occlusion pop-in without a single line of code from you.
The catch is that the default is identical in ten thousand other games. The moment your design wants a fixed combat distance, a shoulder offset that swaps on aim, or a first-person mode that snaps cleanly, you are fighting a module built to be average.
Replacing it is not an all-or-nothing decision. You can disable it entirely and drive the CFrame yourself, or leave it running and override only specific behaviors, but this guide takes full control because that is where the interesting design space lives.
Taking Control — the Scriptable Camera and the Render Loop
Full control starts with one property. Setting CameraType to Scriptable tells the default module to stand down and stop writing to the camera.
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local camera = workspace.CurrentCamera
camera.CameraType = Enum.CameraType.ScriptableFrom here nothing moves the camera except you. The next decision is where your update code runs, because a camera that updates on the wrong step will visibly lag the character it is following.
Drive a custom camera from RunService:BindToRenderStep at Enum.RenderPriority.Camera.Value. That runs after input is read but before the character renders, so the frame the player sees is always consistent.
Use BindToRenderStep rather than a raw RenderStepped connection when you want an explicit priority relative to input and character updates. It is the same hook Roblox's own camera scripts use, and it lets you name and unbind the loop cleanly when you switch modes.
Reading Mouse Input for Yaw and Pitch
A camera controller is really just two accumulated angles — yaw around the vertical axis and pitch up and down — driven by mouse movement. Lock the mouse to the center of the screen so it never leaves the window, then read the per-frame delta.
local UserInputService = game:GetService("UserInputService")
local yaw, pitch = 0, 0
local SENSITIVITY = 0.4
UserInputService.MouseBehavior = Enum.MouseBehavior.LockCenter
UserInputService.InputChanged:Connect(function(input)
if input.UserInputType == Enum.UserInputType.MouseMovement then
yaw = yaw - input.Delta.X * SENSITIVITY
pitch = math.clamp(pitch - input.Delta.Y * SENSITIVITY, -75, 75)
end
end)The math.clamp on pitch is the line that keeps your camera from flipping over the top of the character and turning the world upside down. Clamping to roughly seventy-five degrees each way matches what players expect from every third-person game they have played.
Keep in mind that input.Delta only reports movement while the mouse is locked, so LockCenter and the delta read are a matched pair. On touch and gamepad you would read the same yaw and pitch from Enum.KeyCode.Thumbstick2 instead, which is why storing them as plain numbers pays off later.
Building the Third-Person Orbit Controller
With angles in hand, the third-person camera is a point that orbits an anchor above the character at a fixed distance. Each frame you rebuild a rotation from the two angles, push the camera back along it, and aim it at the anchor.
local DISTANCE = 12
local function updateThirdPerson()
local character = Players.LocalPlayer.Character
if not character then return end
local root = character:FindFirstChild("HumanoidRootPart")
if not root then return end
local origin = root.Position + Vector3.new(0, 2, 0)
local rotation = CFrame.fromEulerAnglesYXZ(math.rad(pitch), math.rad(yaw), 0)
local offset = rotation:VectorToWorldSpace(Vector3.new(0, 0, DISTANCE))
camera.CFrame = CFrame.lookAt(origin + offset, origin)
end
RunService:BindToRenderStep("ThirdPerson", Enum.RenderPriority.Camera.Value, updateThirdPerson)The Vector3.new(0, 2, 0) offset lifts the orbit point to roughly shoulder height so the character is not sitting dead-center at their feet. Raising or lowering that vector is how you tune a heroic low angle versus a tactical top-down feel without touching anything else.
Third-person orbit is CFrame.lookAt(origin + offset, origin), where origin is the anchor above the character and offset pushes the camera back by your distance. Tune distance and shoulder offset to reshape the feel.
A shoulder offset — nudging the camera a meter to the right by shifting the origin sideways — is the single change that makes an orbit rig read as a modern action camera rather than a dead-center follow. It is also where your camera and a responsive Roblox combat system start to feel like one system rather than two.
Keeping the Camera Out of Walls — Occlusion Raycasts
An orbit camera pushed twelve studs back will happily bury itself in the wall behind the player, leaving them staring at a texture. The fix is a raycast from the anchor toward the desired camera position every frame.
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { character }
local desired = origin + offset
local result = workspace:Raycast(origin, offset, params)
if result then
camera.CFrame = CFrame.lookAt(result.Position + result.Normal * 0.5, origin)
else
camera.CFrame = CFrame.lookAt(desired, origin)
endWhen the ray hits geometry, you seat the camera just in front of the hit point using result.Normal so it never clips through the surface. When it hits nothing, the camera sits at its full desired distance and the player never notices the system working.
Stop camera clipping by raycasting from the character to the desired camera spot each frame. On a hit, place the camera at result.Position offset along result.Normal; on a miss, use full distance.
Excluding the character from the ray is not optional — skip it and your own body registers as the first hit, snapping the camera to your back on every frame. For a smoother result, interpolate the distance toward the target instead of snapping, so the camera glides out from a wall rather than popping.
Switching to First Person — What Actually Changes
First person reuses the same yaw and pitch, but the camera stops orbiting and sits inside the character's head. The distance collapses to zero and the field of view usually opens up to sell the sense of being there.
local FP_FOV = 80
local function updateFirstPerson()
local character = Players.LocalPlayer.Character
local head = character and character:FindFirstChild("Head")
if not head then return end
local rotation = CFrame.fromEulerAnglesYXZ(math.rad(pitch), math.rad(yaw), 0)
camera.CFrame = CFrame.new(head.Position) * rotation
camera.FieldOfView = FP_FOV
endThe second half of first person is the character itself: the body has to turn with the camera instead of the camera turning around the body. Set the Humanoid's AutoRotate to false and rotate the HumanoidRootPart to match your yaw, or the player will see their own head swivel independently of their torso.
First-person camera sits at the Head CFrame times your yaw-pitch rotation, with field of view around 80. Disable Humanoid.AutoRotate and turn the root to the yaw so the body faces where the camera looks.
You will also want to hide the local character's head and hats so they do not fill the lens, which you do by dropping their LocalTransparencyModifier to one. A clean crosshair and the rest of your heads-up display belong in the same client layer, so plan the first-person view alongside your Roblox UI systems rather than bolting it on afterward.
Zoom, Smoothing, and the Details That Sell It
Two touches turn a functional camera into one that feels expensive: a scroll-wheel zoom and a little smoothing on the position. Neither is required for the camera to work, and both are what players unconsciously register as polish.
Read the wheel from the same InputChanged handler using Enum.UserInputType.MouseWheel and input.Position.Z, then clamp the distance between a sensible minimum and maximum. Feed that variable into the DISTANCE you push the camera back, and the orbit tightens and loosens on demand.
For smoothing, interpolate the camera's CFrame toward its target with CFrame:Lerp rather than assigning the target directly. A lerp factor tied to the frame's delta time gives you a camera that eases into motion, which reads as weight instead of the rigid snap of a raw assignment.
Add polish with a mouse-wheel zoom that clamps distance and a CFrame:Lerp toward the target instead of a direct assign. The lerp eases camera motion so it reads as weight rather than a rigid snap.
Third-Person vs First-Person at a Glance
The two controllers share their input layer and diverge only in anchor, distance, and field of view. This table maps the decisions that differ between them.
| Concern | Third-Person | First-Person |
|---|---|---|
| Camera anchor | Point above HumanoidRootPart | Head position |
| Distance | 10–14 studs, tunable | Zero |
| Field of view | 65–70 for scale | 80–90 for immersion |
| Occlusion raycast | Required | Not needed |
| Character rotation | Camera orbits the body | Body turns to the camera |
| Local character | Fully visible | Head and hats hidden |
Notice how much is shared: the same yaw, pitch, sensitivity, and render binding drive both. That is the argument for building your own — a mode switch becomes a matter of unbinding one update function and binding another.
Where Camera Code Belongs in Your Frame Budget
Camera code runs every single frame, which means a sloppy update loop is felt as stutter more directly than almost anything else in your game. A raycast, a couple of CFrame constructions, and some arithmetic per frame is cheap, but the discipline around it still matters.
Here is where the cost actually lives and how to keep it flat:
- Bind at the right priority. Running at Enum.RenderPriority.Camera.Value keeps input, camera, and character in the order Roblox expects, avoiding a one-frame lag between the character moving and the camera catching up.
- One raycast per frame, not several. A single occlusion ray is fine, but a fan of rays for smooth collision adds up fast, so profile before you add them.
- Cache your lookups. Calling FindFirstChild every frame is acceptable, yet hoisting the character reference and reusing it is cheaper and avoids surprises during respawns.
If the camera ever feels like it is dropping frames, the render step is the first place to measure, not to guess. Treat it the way you would any hot path and lean on your Roblox performance profiling workflow to confirm where the time is going before optimizing.
A Note on Trust — the Camera Is Client-Owned
Every line above runs on the client, and that is not an accident — the camera has to react at the local frame rate, so it cannot live on the server. This is the opposite of your authoritative game state, which should never trust the client's word for anything.
The practical rule is that the camera can look wherever the player points it, but the server decides what that look is allowed to do. Aim direction sent from the client is only a claim, and it belongs behind the same validation as any other input in a solid Roblox anti-exploit layer.
After all, the camera reads local input and writes the local CFrame, while movement and hit detection replicate through your Roblox replication model. Organizing both as focused modules follows the same Luau scripting patterns you would use anywhere else in the codebase.
Bringing Both Controllers Together
A custom camera is one of the highest-leverage systems you can build in Roblox, because it is felt continuously and shared by both perspectives you are likely to ship. Start with the scriptable takeover and the yaw-pitch input, get the third-person orbit feeling right, then layer occlusion and a first-person mode on the same foundation.
Build it as its own module, keep the render step lean, and treat the client camera as a request rather than a source of truth. Do that and you have a controller you can carry from one project to the next, and the kind of ownership that shows up in how the whole game feels to play.


