Back to Projects

Camera Bobbing

Released
Roblox
Lua
Camera
First Person
System

A Roblox first-person camera bobbing system that makes movement feel natural and immersive. It reacts to speed with sinusoidal sway, tilts on strafing, rolls with mouse movement, and delivers a jump kick on takeoff and a land impact on touchdown scaled by airtime.

Configuration

Every tunable value is defined at the top of the script. Bob amplitude, tilt strength, roll limits, jump kick angle, and land impact scale can all be adjusted without touching the logic below.

CameraBobbing (LocalScript).lua
1-- noise amplitudes
2local BOB_X_MIN = 5
3local BOB_X_MAX = 20
4local BOB_Y_MIN = 2
5local BOB_Y_MAX = 10
6local BOB_X_CLAMP = 0.15   -- max bob pitch degrees
7local BOB_Y_CLAMP = 0.5    -- max bob yaw degrees
8local TILT_STRENGTH = 0.05 -- strafe tilt scale
9local MAX_TILT = 0.05      -- strafe tilt clamp (radians)
10local MAX_ROLL = 2.5       -- max mouse roll degrees
11local SPEED_WALK = 12      -- sX while walking
12local SPEED_RUN  = 20      -- sX while running
13local AMP_Y = 18           -- sY while moving
14-- Jump/land config
15local JUMP_KICK  = 10  -- degrees upward on jump
16local LAND_SCALE = 10  -- impact strength per sec of airtime
17local LAND_MAX   = 30  -- max land impact degrees
18local LAND_DECAY = 6   -- how fast punch fades

Jump and land punch

Airtime is accumulated every frame while the character is in freefall. On landing, punchY is set to a negative pitch proportional to how long they were airborne, then lerped back to zero each frame via LAND_DECAY.

CameraBobbing (LocalScript).lua
1humanoid.StateChanged:Connect(function(_, new)
2	if new == Enum.HumanoidStateType.Jumping then
3		punchY = JUMP_KICK
4	elseif new == Enum.HumanoidStateType.Freefall then
5		inAir = true
6	elseif new == Enum.HumanoidStateType.Landed then
7		punchY = -math.clamp(airTime * LAND_SCALE, 0, LAND_MAX)
8		airTime = 0
9		inAir = false
10	end
11end)

RenderStepped loop

Every frame the punch offset is re-applied after being lerped toward zero, the sinusoidal bob values are nudged toward their targets based on current speed, and the strafe tilt and mouse roll are blended in. The previous punch CFrame is inverted and removed before the new one is applied so offsets never stack.

CameraBobbing (LocalScript).lua
1runService.RenderStepped:Connect(function(dt)
2	if inAir then airTime += dt end
3	punchY = lerp(punchY, 0, LAND_DECAY * dt)
4	local newPunchOffset = CFrame.Angles(math.rad(punchY), 0, 0)
5
6	-- remove last frame's punch before recalculating
7	camera.CFrame = camera.CFrame * lastPunchOffset:Inverse()
8
9	local velocity = root.Velocity.Magnitude
10	if velocity > 0.02 then
11		dt *= 60
12		local randomX = math.random(1, 2)
13		local randomY = math.random(1, 2)
14		local t = tick()
15		vX = dt <= 2 and lerp(vX, math.cos(t * 0.5 * randomX) * (math.random(BOB_X_MIN, BOB_X_MAX) / 200) * dt, 0.05 * dt) or 0
16		vY = dt <= 2 and lerp(vY, math.cos(t * 0.5 * randomY) * (math.random(BOB_Y_MIN, BOB_Y_MAX) / 200) * dt, 0.05 * dt) or 0
17
18		camera.CFrame *= CFrame.Angles(0, 0, math.rad(angleX))
19			* CFrame.Angles(math.rad(math.clamp(x * dt, -BOB_X_CLAMP, BOB_X_CLAMP)), math.rad(math.clamp(y * dt, -BOB_Y_CLAMP, BOB_Y_CLAMP)), tilt)
20			* CFrame.Angles(math.rad(vX), math.rad(vY), math.rad(vY * 10))
21
22		-- strafe tilt: project velocity onto camera's local X axis
23		tilt = math.clamp(lerp(tilt, -camera.CFrame:VectorToObjectSpace(
24			(root.Velocity) / math.max(humanoid.WalkSpeed, 0.01)).X * TILT_STRENGTH, 0.1 * dt), -MAX_TILT, MAX_TILT)
25
26		if not touchEnabled and dt < 2 then
27			angleX = lerp(angleX, math.clamp(userInputService:GetMouseDelta().X / dt * 0.15, -MAX_ROLL, MAX_ROLL), 0.25 * dt)
28		end
29
30		x = lerp(x, math.sin(t * sX) / 5 * math.min(1, sY / 10), 0.25 * dt)
31		y = velocity > 1 and lerp(y, math.cos(t * 0.5 * math.floor(sX)) * (sX / 200), 0.25 * dt) or lerp(y, 0, 0.05 * dt)
32		sX, sY = velocity > 12 and SPEED_RUN or (velocity > 0.1 and SPEED_WALK or 0), velocity > 0.1 and AMP_Y or 0
33	end
34
35	camera.CFrame *= newPunchOffset
36	lastPunchOffset = newPunchOffset
37end)

Touch support

Mouse roll is automatically disabled on touch devices — `userInputService.TouchEnabled` is checked once at startup and the `angleX` branch is skipped entirely, so it works on mobile without any extra config.