§ How to · with Picoo

How to make a custom camera in Roblox

By Sametcan Tasgiran, Founder & Developer·Published ·Updated

A custom camera that snaps back to default every frame is not broken code — it is the default camera script still doing its job.

Cameras are client-only, and every reliable camera in Roblox comes down to three things: the right CameraType, the right update event, and framerate-independent smoothing.

Scriptable camera control

CameraType set so your CFrame survives past the next frame.

Smooth follow

Framerate-independent interpolation — identical feel at 30 and 240 FPS.

Camera modes

Over-the-shoulder, first person, top-down and fixed cinematic, switchable at runtime.

Shake and recoil

Additive offsets that layer onto the follow camera instead of fighting it.

Zoom limits

MinZoomDistance and MaxZoomDistance for locking first or third person.

Occlusion handling

A raycast pulls the camera in when geometry gets between it and the player.

Files Picoo ships for this prompt

2 files · 175 lines · ~30s · 1 credit

CameraController

Mode switching, follow maths, shake stack, occlusion raycast.

130 lines

CameraModes

Per-mode offsets, FOV and sensitivity as data.

45 lines

Sample output: StarterPlayerScripts.CameraController

-- Cameras exist ONLY on the client. workspace.CurrentCamera on the server is not
-- the player's camera, and setting it there does nothing.
local cam = workspace.CurrentCamera

-- Without Scriptable, Roblox's default camera script rewrites CFrame every frame
-- and your camera silently snaps back. This is the #1 "my camera doesn't work".
cam.CameraType = Enum.CameraType.Scriptable

-- Framerate independence: a plain lerp with a constant alpha is FASTER at high
-- FPS because it runs more often. Correcting by dt makes the feel identical
-- whether the player is on a phone at 30 FPS or a desktop at 240.
local SMOOTH = 0.15
local function smoothed(alpha: number, dt: number): number
	return 1 - (1 - alpha) ^ (dt * 60)
end

RunService:BindToRenderStep("PicooCamera", Enum.RenderPriority.Camera.Value, function(dt)
	local root = getRoot()
	if not root then return end

	local goal = CFrame.new(root.Position)
		* CFrame.Angles(0, yaw, 0)
		* CFrame.new(mode.offset)

	cam.CFrame = cam.CFrame:Lerp(goal, smoothed(SMOOTH, dt)) * shakeOffset()
end)

Building a custom camera in Roblox

Almost every custom camera problem in Roblox is one of three things, and none of them are about maths.

The first is control. Roblox ships a camera script that runs every frame and writes CurrentCamera.CFrame. If you set the CFrame yourself without claiming ownership, that script overwrites it before the frame is drawn, and the camera appears to ignore your code entirely. Setting CameraType to Scriptable is what makes it stop — and setting it back to Custom is how you return control when your cutscene ends.

The second is location. Cameras are per-player and exist only on the client. There is a CurrentCamera on the server, but it is not any player's view, and writing to it accomplishes nothing. Camera code lives in a LocalScript; when the server needs a camera change, it fires a remote and the client acts on it.

The third is timing, and it is the one that separates a camera that feels good from one that feels cheap. Bind to the render step at camera priority, so your update runs where the default camera's would have — on Heartbeat you end up a frame behind the render and get a jitter players notice without identifying. Then correct your interpolation for delta time. A constant lerp alpha converges faster when the frame rate is higher, so a follow that feels smooth on your desktop feels sluggish on a phone. Raising the alpha to the power of dt times 60 makes the behaviour identical everywhere.

Once those three are right, everything else is layering: an additive shake offset applied after the follow, a raycast that pulls the camera in when a wall gets between it and the player, and per-mode offsets kept as data so adding a top-down mode is a table entry rather than a new branch.

See more on the Luau generator, the game builder, or browse the full blog.

Frequently asked

Why does my camera snap back to normal?+

CameraType is not Scriptable. Roblox's built-in camera module writes CurrentCamera.CFrame every frame, so unless you take control by setting CameraType to Scriptable, your value is overwritten before it is ever drawn. Setting it back to Custom is how you hand control back.

Can I control the camera from a Script on the server?+

No. Each player has their own camera and it exists only on their client — workspace.CurrentCamera on the server is not it. Camera code belongs in StarterPlayerScripts or another LocalScript. If the server needs to trigger a camera change, fire a RemoteEvent and let the client do it.

RenderStepped or Heartbeat?+

BindToRenderStep with Enum.RenderPriority.Camera.Value, so your update runs at the same point in the frame the default camera would have. Updating on Heartbeat puts the camera a frame behind the render and produces a subtle jitter that people describe as "the camera feels laggy" without being able to say why.

Why does my camera feel different on other devices?+

A fixed lerp alpha per frame is framerate-dependent — it converges faster when frames come faster. Correct for delta time (1 - (1 - alpha) ^ (dt * 60)) and the same code feels the same at 30 and 240 FPS.

How do I lock first person?+

Set Player.CameraMinZoomDistance and CameraMaxZoomDistance to the same small value (0.5) — that pins the zoom without going Scriptable, so the default camera keeps handling mouse look for you. Only go Scriptable when you need control the default camera cannot give.

Related Picoo prompts