MediumCustom Bird’s Eye Camera System in Roblox | by Peakk | Medium
Sitemap
Peakk

Peakk

Peakkofficial website development service & website design service

Custom Bird’s Eye Camera System in Roblox

6 min readMar 21, 2026

--

By Peakk — Game development & Product Designer

View the full code at https://gist.github.com/Peakk2011/2f29dfc5d10ec3957dd6db9e264beaef

Before started why the default camera isn’t enough because of Roblox ships with a default camera that works fine for the most games.

But moment that you want something like a top down(bird eye view) strategy game, an isometric RPG, or a tactical shooter you hit a wall fast.

The default Classic and Follow modes are locked to third-person over-the-shoulder. You can't reposition it without fighting the engine.

That is why scripting your own camera controller is the one if the most best thing on Roblox developer can learn. And it’s not just about making the camera “going up” to build it better you need to under these thing that the Roblox engine is doing under the hood like Cframes , RenderSpeed and linear interpolation (lerp).

This notes walks through the camera system a bird’s eye view I’ll explain not just what the code does, but why it’s written that way.

Math Behind Camera

CFrame: Position + Rotation in One Object

In Roblox, a CFrame (Coordinate Frame) is a matrix that stores both a position in 3D space and an orientation. When you set Camera.CFrame, you're telling the engine exactly where the camera is and which direction it's pointing.

local camOffset = CFrame.new(rootPos)
* CFrame.fromEulerAnglesYXZ(0, State.CurrentYaw, 0)
* CFrame.new(0, Config.Height, Config.Tilt)

Breaking this down step by step:

  1. CFrame.new(rootPos) — Start at the player's position
  2. * CFrame.fromEulerAnglesYXZ(0, State.CurrentYaw, 0) — Rotate that frame by the current yaw (horizontal mouse look)
  3. * CFrame.new(0, Config.Height, Config.Tilt) — Offset upward (height) and forward/back (tilt) relative to that rotated frame

This is matrix multiplication. Because we multiply the offset after the rotation, the offset moves with the rotation — the camera always stays directly above the player, not floating off to the side when you turn.

Finally, CFrame.new(camOffset.Position, rootPos) makes the camera look at the player from that elevated position. This is the bird's eye effect.

The Yaw System — Mouse-Driven Rotation

I want the camera to rotate when the mouse moves horizontally. The logic is:

UserInputService.InputChanged:Connect(function(input)
if input.UserInputType == Enum.UserInputType.MouseMovement then
State.Yaw -= input.Delta.X * Config.Sensitivity
end
end)

input.Delta.X is how many pixels the mouse moved this frame. Multiply by Sensitivity (a small number like 0.003) to turn that into radians. Subtract it so moving right turns the camera clockwise. That's it — the raw math is just radians from pixels.

The “Smooth” Factor

Why Not Just Set Values Directly?

Because if you set Camera.FieldOfView = 80 immediately when the player starts sprinting, the FOV snaps instantly. It looks terrible — jarring and amateurish. The fix is lerping: instead of jumping to the target value, you move a percentage of the remaining distance each frame.

State.CurrentFOV += (targetFOV - State.CurrentFOV) * (Config.FOVSmoothing * dt)

This is called an exponential moving average or "lerp toward target." How it works:

  • If CurrentFOV = 60 and targetFOV = 80, the difference is 20
  • Multiply by FOVSmoothing * dt (e.g. 0.18) it move 3.6 units this frame
  • Next frame: difference is 16.4, move 2.95… and so on

The value approaches the target quickly at first, then slows down as it gets close. This gives you that buttery-smooth easing for free, no TweenService required.

The same pattern applies to yaw smoothing and the screen brightness effect:

State.CurrentYaw += (State.Yaw - State.CurrentYaw) * (Config.RotationSmoothing * 60 * dt)
State.CurrentBrightness += (targetBrightness - State.CurrentBrightness) * (Config.RecoveryBrightSmooth * dt)

Why Multiply by dt?

dt is delta time — the time in seconds since the last frame. On a 60fps machine that's ~0.0167. On a 30fps machine it's ~0.033.

If you don’t multiply by dt, your smoothing speed is tied to framerate. At 144fps the camera barely moves; at 30fps it snaps. Multiplying by dt makes the behavior frame-rate-independent — the camera moves the same "real-world speed" regardless of hardware.

Mechanics

RenderStepped vs Heartbeat vs Wait()

Get Peakk’s stories in your inbox

Join Medium for free to get updates from this writer.

This is where most tutorials get it wrong. My entire camera loop runs inside RunService.RenderStepped:

renderConnection = RunService.RenderStepped:Connect(function(dt)
-- all camera logic here
end)

The three options compared:

Runs whenUse forRenderSteppedBefore each frame rendersCamera, visual effectsHeartbeatAfter physics stepCharacter movement, game logictask.wait() loopWhenever the scheduler gets to itNon-time-sensitive tasks

RenderStepped runs before the frame is drawn, so if you update Camera.CFrame here, the player sees zero lag — the camera position is already set before the image is composited. Using Heartbeat or a wait() loop means the camera updates after the frame has already been drawn once, introducing a one-frame delay that manifests as a subtle but nauseating stutter.

The Stamina System

The stamina system drives three separate visual layers simultaneously:

1. Camera shake when exhausted:

State.ShakeTime += dt * Config.ShakeFrequency
local str = Config.ShakeAmplitude * (1 - State.Stamina / Config.MaxStamina)
shakeX = math.sin(State.ShakeTime * 1.3) * math.sin(State.ShakeTime * 0.7) * str
shakeY = math.sin(State.ShakeTime) * math.cos(State.ShakeTime * 1.1) * str * 0.6

Multiplying two sin waves with different frequencies creates an irregular, non-repeating pattern — not a robotic metronome shake. str scales amplitude with how depleted stamina is, so the shake builds gradually.

2. Head bob during recovery:

bobY = math.sin(t) * Config.RecoveryBobAmplitude
bobX = math.cos(t * 0.5) * Config.RecoveryBobAmplitude * 0.4
bobRoll = math.sin(t) * Config.RecoveryRollAmount

Three separate axes for more naturalistic movement. The roll (camera tilt) gives a “hunched over catching breath” feel that pure up-down bob doesn’t have.

3. Screen darkening via ColorCorrectionEffect:

local targetBrightness = (isExhausted or State.IsExhausting) and Config.RecoveryDarken or 0
State.CurrentBrightness += (targetBrightness - State.CurrentBrightness) * (Config.RecoveryBrightSmooth * dt)
ColorCorrectionEffect.Brightness = State.CurrentBrightness

Same lerp pattern, this time on a ColorCorrectionEffect in Lighting. The screen gradually dims when stamina runs out and recovers as the player rests. Cheap to compute, high impact on feel.

The Exhaustion Lock

One subtle design decision: when the player runs out of stamina, they don’t immediately recover the moment they stop sprinting. There’s a forced 3–5 second “exhaustion lock”:

State.ExhaustionTimer = math.random(3, 5)

Without this, a skilled player could micro-manage their sprinting to avoid the penalty entirely by tapping shift rhythmically. The random duration makes it feel organic and prevents exploitation. This is a game design decision expressed through code — not just mechanics, but intention.

Performance Notes

A few things worth noting about keeping this script efficient:

Disconnect the old connection on respawn. Because SetupCamera fires on every CharacterAdded, I store the RenderStepped connection and disconnect it before creating a new one. Without this, every death spawns a new loop running in parallel — your camera jitters, performance degrades.

lua

if renderConnection then renderConnection:Disconnect() end
renderConnection = RunService.RenderStepped:Connect(...)

Avoid creating new objects inside the loop. Every CFrame.new() and Vector3.new() inside RenderStepped allocates memory every frame (60+ times per second). I minimize temporary construction by reusing calculated values. Roblox's Luau GC handles small allocations fine, but it's a good habit to build.

Humanoid.AutoRotate = false. By default, Roblox auto-rotates the HumanoidRootPart to face the movement direction. Since I'm controlling rotation manually through RootPart.CFrame, leaving this on creates a fighting condition where the engine and my script both try to set the character's rotation simultaneously. Disabling it hands full control to the script.

Conclusion

A camera controller is a great project precisely because it sits at the intersection of math, performance, and feel. You can’t fake understanding it — either the camera feels right or it doesn’t.

The three concepts worth taking away from this:

  • CFrame multiplication is how you offset and rotate things relative to each other in 3D space
  • Lerp-toward-target is your most versatile tool for smooth transitions — works for position, rotation, FOV, color, anything scalar
  • RenderStepped + dt is the correct loop for anything that affects what the player sees each frame

The full script is available on my GitHub. If you are building something on top of this, the cleanest next step is separating the stamina logic into its own ModuleScript the camera controller shouldn’t need to know the game rules, it should just receive a “state” and react to it.

About me

My name is Peakk a developer and designer from Thailand Bangkok
I also builds Web frameworks, Design systems, Web infrastructure and Custom typefaces you can approach me at

--

--

Peakk
Peakk

Written by Peakk

0 followers

Peakkofficial website development service & website design service