Crafting a Dynamic Third-Person Camera in Godot

Posted by Gemma Ellison
./
July 21, 2025

Is your game’s camera stuck in a boring, static view? Do you yearn for a dynamic perspective that truly immerses players in your world? Then ditch the default and let’s build a robust, third-person camera in Godot that’s both functional and feels great. We’re not just slapping a camera behind the player; we’re crafting a cinematic experience with smooth follows, intelligent collision avoidance, and rock-solid player-relative positioning.

Scene Setup: Laying the Foundation

First, let’s structure our Godot scene. Create a new 3D scene. This will hold your player character and the camera. Add a CharacterBody3D node for your player. This will handle movement and collision.

Next, create a Camera3D node as a child of the CharacterBody3D. This parent-child relationship is critical for player-relative positioning. Rename the camera node something meaningful, like PlayerCamera.

Finally, add a CollisionShape3D to the CharacterBody3D to define the player’s physical boundaries. This is essential for movement and for the camera’s collision avoidance system later.

GDScript: The Brains of the Operation

Now for the fun part – the code! Attach a new GDScript to your CharacterBody3D node. This script will handle both player movement and camera control.

extends CharacterBody3D

@export var speed = 5.0
@export var camera_offset = Vector3(0, 2, 5) #X,Y,Z offset for your camera. Adjust to taste.
@export var rotation_speed = 2.0

func _physics_process(delta):
    # Handle Movement
    var input_dir = Vector2.ZERO
    input_dir.x = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
    input_dir.y = Input.get_action_strength("move_down") - Input.get_action_strength("move_up")
    var direction = Vector3(input_dir.x, 0, input_dir.y).rotated(Vector3.UP, $PlayerCamera.rotation.y).normalized()

    if direction:
        velocity.x = direction.x * speed
        velocity.z = direction.z * speed
    else:
        velocity.x = move_toward(velocity.x, 0, speed)
        velocity.z = move_toward(velocity.z, 0, speed)

    move_and_slide()

    # Handle Camera Rotation
    var target_rotation = -atan2(direction.z, direction.x) - PI/2
    $PlayerCamera.rotation.y = lerp_angle($PlayerCamera.rotation.y, target_rotation, rotation_speed * delta)

This script first handles basic player movement based on input. Critically, it transforms the input direction into world space based on the camera’s rotation. This ensures the player moves relative to the camera’s perspective.

Next, it implements smooth camera rotation. The script calculates the target rotation based on the player’s movement direction and uses lerp_angle for a smooth, natural rotation effect. Don’t use a standard lerp here, lerp_angle correctly handles angle wrapping preventing the camera suddenly spinning the wrong way when the player crosses the -180/180 degree boundary.

A common mistake is directly setting the camera’s position. While tempting, this leads to jerky movement. Instead, we use the parent-child relationship to ensure the camera stays relative to the player. The camera_offset variable controls the camera’s position relative to the player.

Collision Avoidance: No More Wall Hugging

Clipping through walls is a camera killer. Let’s implement basic collision avoidance using raycasting. Add this code inside your _physics_process function, before the camera rotation code:

    #Collision Avoidance
    var target_position = position + camera_offset.rotated(Vector3.UP, $PlayerCamera.rotation.y)
    var camera_ray_length = camera_offset.length()

    var space_state = get_world_3d().direct_space_state
    var query = PhysicsRayQueryParameters3D.new()
    query.from = position
    query.to = target_position
    query.exclude = [self]

    var result = space_state.intersect_ray(query)

    if result:
        $PlayerCamera.position = camera_offset.normalized() * result.distance
    else:
        $PlayerCamera.position = camera_offset

Here’s the breakdown:

  • We define a target_position representing the desired camera position without collision avoidance.

  • We create a PhysicsRayQueryParameters3D to configure our raycast. exclude = [self] is crucial; otherwise, the raycast will immediately collide with the player.

  • intersect_ray performs the raycast. If it hits something, result will contain collision information.

  • If a collision occurs, we shorten the camera’s distance to the player based on the result.distance. This pulls the camera closer, preventing clipping. If no collision, the camera remains at the desired camera_offset.

The biggest pitfall here is forgetting to exclude the player from the raycast. This simple line prevents hours of frustrating debugging.

Practical Value: Beyond the Basics

This setup provides a solid foundation. But let’s add some polish:

  • Camera Zoom: Implement zoom functionality using the mouse wheel. Modify the camera_offset.length() based on the wheel input, clamping the value to prevent extreme zoom levels.

  • Obstruction Transparency: Instead of simply moving the camera, consider making obstructing objects partially transparent. This can provide a better sense of spatial awareness. Use VisualServer.mesh_surface_set_material_override to temporarily change the material of the obstructing object. Remember to revert the material when the obstruction is gone.

  • Advanced Collision Handling: Raycasting is a simple solution, but it can be inaccurate in complex environments. Consider using a ShapeCast3D for more robust collision detection.

By implementing these techniques, you’ll craft a third-person camera system that feels intuitive, responsive, and truly elevates the player’s experience. Don’t settle for a basic camera; build a cinematic window into your game world!