Almost every Godot project starts its camera the same way. You add a Camera2D as a child of the player, press Play, and the camera follows you around. It works. Nothing is broken. But watch it for thirty seconds and something feels off: the whole screen answers every tiny step you take, the ground bounces on every jump, and a long fall hides the thing you are about to land on.
Then you open the Inspector and start turning things on. Position smoothing. Drag margins. Limits. The camera gets softer, but it does not get smarter, because none of those settings know when the camera should move, where it should look, or what part of the level actually matters right now.
That is the real problem. A good camera moves because the frame needs to change, not because the player moved. So here is how I rebuilt Godot's default camera around that idea: a separate rig, a dead zone, eased look-ahead, jump-aware vertical tracking, camera zones the level controls, and effects that sit on top without disturbing any of it.
Everything below is in the project from the video, and you can grab it here:
The complete CameraRig project
The full Godot 4 project: camera_rig.gd, camera zones, effects, a debug overlay, and nine playable scenes that walk from Godot's default camera to the finished rig, one behavior at a time.
Godot 4 · GDScript · 2.9 MB · .zip
Why the default camera feels bad
A Camera2D parented to the player is a hard link. Player position becomes camera position, one to one, forever. Every twitch of input, every landing bounce, every mid-air correction is a camera move.
The built-in settings soften that link, they do not break it:
- Position smoothing delays the same movement. Your corrections still move the frame, just late.
- Drag margins are close to a dead zone, and they are genuinely useful, but they still work off raw position. They cannot tell a nervous shuffle from a committed sprint.
- Limits stop the view leaving the level. They say nothing about what should be visible inside it.
Turn all of them up and the camera goes floaty, which reads as "worse" even though technically you added more features. Nothing in that list makes a decision. So we build the thing that does.
Give the camera its own target
First move: the camera stops being attached to the player.
- Player : CharacterBody2DscriptThe usual setup: the camera is welded to the player.
- Sprite2D : Sprite2D
- CollisionShape2D : CollisionShape2D
- Camera2D : Camera2DEvery player move is a camera move.
- World : Node2DThe rebuild: three jobs, three nodes.
- Player : CharacterBody2DscriptSays what is happening.
- CameraRig : Node2Dscript(selected)Decides how it should be framed.
- Camera2D : Camera2DZoom, limits and effects only.
Three responsibilities, cleanly split. The player reports what is happening. The rig decides how it should be framed. The Camera2D handles zoom, limits, and effects, and nothing else.
The rig eases toward its target with frame-rate independent interpolation, so the camera behaves the same at 60 and 144 fps:
func _move_rig(delta: float) -> void: # Frame-rate independent interpolation. Higher follow_speed = snappier. var weight := 1.0 - exp(-follow_speed * delta) global_position = global_position.lerp(desired_position, weight)That 1.0 - exp(-speed * delta) is worth memorizing. A plain lerp(a, b, 0.1) moves a different distance per second depending on frame rate, so the same camera feels different on different machines. This version does not.
One thing people forget: since the rig owns the smoothing now, the Camera2D must not add its own on top.
func _ready() -> void: add_to_group(&"camera_rig") # The rig owns the smoothing, so the Camera2D must not add its own. camera.position_smoothing_enabled = false camera.drag_horizontal_enabled = false camera.drag_vertical_enabled = falseAt this point the camera is independent, which is progress. It still moves through every tiny correction, which is the next problem.
Keep it still: the dead zone
When you shuffle back and forth lining up a jump, the character should move. The frame should not.
So the rig does not follow the player directly. It remembers an anchor, and the player pushes that anchor only when they reach the edge of a box around it:
func _update_tracked() -> void: var player_x := target.global_position.x # Only the edges of the box push the anchor. Everything inside is free. if player_x < tracked_position.x - dead_zone_x: tracked_position.x = player_x + dead_zone_x elif player_x > tracked_position.x + dead_zone_x: tracked_position.x = player_x - dead_zone_xInside the dead zone, the anchor never changes, so the rig has nothing to chase and the frame holds perfectly still. Cross an edge and the anchor gets dragged along with you, staying exactly one dead-zone width behind.
That is the whole trick, and it fixes the twitchiness immediately. It also creates a new problem: the frame is now stable in the wrong place.
Then look ahead
Running right means you need to see what is to the right. The player is dead center, so half your screen is showing level you already crossed, and the gap you are sprinting toward appears at the edge with no time to react.
The fix is to offset the target in your direction of travel:
var speed_x := _target_velocity().xvar goal := 0.0# Below the commit speed we treat the input as a correction, not a commitment.if absf(speed_x) > look_ahead_commit_speed: goal = signf(speed_x) * look_ahead_distanceMy first version applied that value directly. It was awful. Every quick tap of the opposite direction threw the whole screen across the level and back. The offset was right, the timing was not.
So ease it instead:
var weight := 1.0 - exp(-look_ahead_speed * delta) look_ahead = lerpf(look_ahead, goal, weight)Same easing curve as the rig itself, just slower. Now a quick correction moves the character and barely nudges the frame, because the value has no time to travel. Actually commit to a direction and the frame follows you over about half a second, opening up the space you are heading into.
Two numbers do all the work here. look_ahead_commit_speed decides what counts as intent: the project uses 40 against a run speed of 260, so a barely-moving nudge never even registers. look_ahead_speed decides how patient the frame is, at 2.2, noticeably slower than the rig's own 6.0.
Stop following every jump
Now the horizontal behavior is good, and vertical is still broken, because a jump is not the same kind of movement.
A normal jump goes up and comes right back down to the same ground. If the camera follows that arc, the platform you are aiming at slides around under you at the exact moment you need it to stay put. So vertically the rig uses a much bigger dead zone, and it remembers a stable height:
if player_y < tracked_position.y - vertical_dead_zone: tracked_position.y = player_y + vertical_dead_zoneelif player_y > tracked_position.y + vertical_dead_zone: tracked_position.y = player_y - vertical_dead_zone# Standing on solid ground slowly re-centres the remembered height, so a# staircase of jumps does not leave the anchor drifting behind.if _target_on_floor(): var weight := 1.0 - exp(-ground_recenter_speed * delta) tracked_position.y = lerpf(tracked_position.y, player_y, weight)The relationship between two numbers is what makes this feel deliberate: in the project a full jump reaches 95 units and the vertical dead zone is 110. A routine jump physically cannot move the frame. Climb onto a genuinely higher platform and you cross the boundary, so the camera rises with you, and the ground re-centring pulls the anchor to your new height once you land.
Falling is the opposite case. Hold the frame still through a long drop and you are landing blind, so the target slides downward as your falling speed grows:
func _update_fall_look(delta: float) -> float: var goal := clampf(_target_velocity().y / fall_speed_reference, 0.0, 1.0) if _target_on_floor(): goal = 0.0 # Ease it so a short hop does not flick the frame downward. var weight := 1.0 - exp(-6.0 * delta) fall_percentage = lerpf(fall_percentage, goal, weight) return fall_percentage * fall_look_distanceStep off a small ledge and the frame barely reacts. Drop off a real cliff and it opens up below you, so the landing is on screen well before you get there. Same code, different amounts, and now the camera knows the difference between jumping, climbing, and falling.
Let the level take control
Everything so far reacts to the player. Some framing problems have nothing to do with the player.
A boss room should show the fight. A vertical tower should favor the space above you. The edge of the map should stop before it reveals the void. No amount of velocity-reading solves those, because they are properties of the space, not the movement.
Two tools cover it. Limits keep the view inside the level, which you can set on the Camera2D directly or clamp on the rig. Camera zones are Area2D regions that temporarily rewrite the rules while you stand in them:
@toolclass_name CameraZoneextends Area2D@export var zone_priority := 0 # highest priority wins when zones overlap@export_group("Framing")@export var override_zoom := false@export var zoom := 1.5@export var vertical_bias := 0.0 # negative looks up, for a tower@export_group("Fixed target")@export var use_fixed_target := false # ignore the player, frame this instead@export var fixed_target_path: NodePath@export_group("Behaviour")@export var override_look_ahead := false@export var look_ahead_distance := 150.0@export var override_follow_speed := false@export var follow_speed := 6.0The zone finds the rig that is following the body that entered it and pushes itself onto the rig's stack:
func _on_body_entered(body: Node2D) -> void: var rig := _rig_following(body) if rig != null: _rig = rig rig.push_zone(self)func _on_body_exited(body: Node2D) -> void: var rig := _rig_following(body) if rig != null: rig.pop_zone(self)The rig then asks for the highest-priority zone it is standing in and lets that zone override individual values, falling back to its own defaults for everything the zone does not care about. That is the part that keeps this maintainable: a zone is a small diff on the normal rules, not a second camera implementation.
In the demo project the same rig handles all five cases with nothing but zone settings. The vertical tower zooms out, biases the frame upward, and locks horizontally. The fast hallway pushes look-ahead to 300 and follow speed to 9. The small room locks onto a fixed centre target. The boss arena uses its own rectangle as the limit, so it frames the whole encounter. And at the map edge the limit simply stops the frame before the emptiness.
The rig handles normal movement. The level takes over when the space needs different framing.
Effects belong on the Camera2D, not the rig
Once the framing is right, feedback goes on top of it. A hard landing wants a small downward offset and a short vertical shake. An attack wants a shake pushing back against the swing.
The important detail is where that displacement lives. Every effect writes to Camera2D.offset, so the rig underneath stays exactly where the framing rules put it:
func _update_effects(delta: float) -> void: var shake_offset := Vector2.ZERO if _shake_elapsed < _shake_duration: _shake_elapsed += delta var falloff := 1.0 - clampf(_shake_elapsed / _shake_duration, 0.0, 1.0) var wave := sin(_shake_elapsed * _shake_frequency * TAU) shake_offset = _shake_dir * wave * _shake_strength * falloff # The landing punch eases back to zero on its own. var weight := 1.0 - exp(-_punch_recover * delta) _punch = _punch.lerp(Vector2.ZERO, weight) camera.offset = shake_offset + _punchNote that the shake takes a direction rather than being random. A landing shakes vertically, because that is where the impact came from. An attack shakes opposite the swing, so it reads as recoil:
func _on_landed(impact_speed: float) -> void: # Falls slower than land_threshold land silently. Not every landing is an event. var t := clampf((impact_speed - land_threshold) / (ceiling - land_threshold), 0.0, 1.0) if t <= 0.0: return _rig.punch(Vector2.DOWN * land_offset * t) _rig.shake(Vector2.DOWN, land_shake * t, land_duration, 20.0)func _on_attacked(direction: float) -> void: # Opposite the swing, so the frame reads as recoil. _rig.shake(Vector2.RIGHT * -direction, attack_shake, attack_duration, 26.0)And scale it to the event. Everything gets multiplied by t, which is how hard you actually hit, and gentle landings return early and produce nothing at all. The demo project ships a gag variant that ignores t and multiplies the shake by nine, and it is genuinely funny for about one second: the character landed, a continent did not split in half. If your camera does that on every hop, you have spent the impact budget on nothing, and the real moments have nothing left to say.
If you want the whole feedback layer (dust, squash and stretch, hit-stop, and sound) I covered that separately in Game Juice: Making the Brackeys Platformer Actually Feel Good. The camera is one instrument in that band.
Tuning it without guessing
Camera values are impossible to reason about on paper. They are hands-on, so the project is built for exactly that: nine scenes that each isolate one behavior, with Tab cycling variants live so you can flip between the before and after without editing anything.
Some starting values from the project, for a game where a full jump is 95 units:
| Value | Setting | What it does |
|---|---|---|
follow_speed | 6.0 | How fast the rig catches up. Higher is snappier, lower is dreamier. |
dead_zone_x | 90.0 | Half-width of the still zone. Bigger ignores more movement. |
look_ahead_distance | 150.0 | How far ahead the frame leads you. |
look_ahead_speed | 2.2 | How patient the lead is. Raise it and corrections start throwing the screen. |
vertical_dead_zone | 110.0 | Must exceed jump height, or jumps move the frame. |
fall_look_distance | 130.0 | How much of the drop is revealed at full falling speed. |
Change one, play, feel it, change it back if it was worse. That loop is the entire skill, and it goes much faster when a scene exists that shows nothing but the value you are turning.
What the camera is actually doing
Strip all of it away and the camera is still just following a target. The difference is what counts as a reason to move.
The dead zone ignores corrections. Look-ahead reveals committed movement. Vertical tracking separates a jump from a climb from a fall. Camera zones let the level speak up when the space matters more than the movement. Effects sit on the offset so none of them disturb the framing.
The default camera asks: did the player move?
A good camera asks: did what the player needs to see change?
Grab the project and start turning knobs
Nine scenes, every value exposed in the Inspector, and a verify tool that re-checks the dead zones, the look-ahead easing, the fall reveal, and frame-rate independence after you retune.
Godot 4 · GDScript · 2.9 MB · .zip
If you want to build the platformer this rig is bolted to, start with the free Move & Jump Platformer Quest, then add the feedback layer in Juice It: Shake, Squash & Particles. And if the jump itself is the part that feels wrong, I rebuilt that from scratch too in I Rebuilt the Default Godot Jump to Feel Amazing.
FAQ
Should the Camera2D be a child of the player in Godot?
For a first prototype it is fine, and it is the fastest thing to set up. Past that it becomes a limitation, because parenting welds camera movement to player movement one to one and leaves you no place to make decisions. Put the Camera2D under its own rig node instead, follow the player from code, and the camera can start ignoring movement that does not matter.
What is a camera dead zone and how big should it be?
A dead zone is a box around a remembered anchor point that the player can move inside without the camera reacting. Only the edges of the box push the anchor. Size it against your character's actual movement: horizontally, wide enough to swallow the small corrections players make before a jump (around 90 units in the demo project), and vertically, larger than a full jump so routine jumps can never move the frame.
How do I stop my Godot camera from following every jump?
Use a vertical dead zone that is bigger than your jump height, and track a remembered ground height instead of the player's exact Y. A normal jump then stays inside the zone and the horizon holds still, while landing on a genuinely higher platform crosses the boundary and moves the frame. Add a downward offset that grows with falling speed so long drops still reveal the landing.
Why does my Godot camera stutter or jitter?
The usual cause is a mismatch in update timing. If you move the camera or its rig in _physics_process, set the Camera2D's process callback to Physics so the camera resolves its transform in the same step. The second cause is doubled smoothing: if your own code eases the camera and Camera2D position smoothing is also on, the two fight and produce a soft, laggy drift. Pick one, and turn the other off.
How do I make a camera zone in Godot 4?
Use an Area2D with a rectangular CollisionShape2D over the part of the level that needs different framing. On body entry, hand the zone to your camera rig; on exit, remove it. Give each zone a priority so overlapping zones resolve predictably, and only override the specific values it cares about (zoom, limits, look-ahead, follow speed, or a fixed target) so everything else keeps using your normal rules.


