---
name: player-controller
description: Use when implementing player movement — CharacterBody2D/3D patterns, input handling, physics, common movement recipes
---

# Player Controllers in Godot 4.3+

All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.

> **Related skills:** **physics-system** for RigidBody, Area, raycasting, and collision shapes, **2d-essentials** for TileMaps, parallax, and 2D lighting, **state-machine** for movement state management, **camera-system** for camera follow and shake, **component-system** for hitbox/hurtbox integration.

---

## 1. Core Concepts

### CharacterBody vs RigidBody

| Body Type         | Use For                        | Physics Control | Notes                                                   |
|-------------------|--------------------------------|-----------------|----------------------------------------------------------|
| `CharacterBody2D/3D` | Player, enemies, NPCs       | Manual (full)   | You control velocity; `move_and_slide()` handles collisions |
| `RigidBody2D/3D`  | Projectiles, props, debris     | Engine-driven   | Physics engine applies forces; harder to control precisely  |
| `RigidBody2D/3D`  | Projectiles with bouncing      | Engine-driven   | Set `linear_velocity` once; let physics resolve bounces  |
| `CharacterBody2D` | Platformers, top-down, FPS    | Manual (full)   | Reliable and predictable; best for responsive game feel  |

**Rule of thumb:** Use `CharacterBody` when you need tight, responsive control. Use `RigidBody` when you want realistic physics simulation.

### The Movement Loop

Every physics frame follows this order:

```
1. Read input          → get axis/action values
2. Apply forces        → gravity, friction, acceleration
3. Modify velocity     → move_toward, lerp, clamp
4. move_and_slide()    → engine resolves collisions, updates position
5. Post-movement state → check is_on_floor(), is_on_wall(), landing events
```

Always put this loop in `_physics_process(delta)`, never `_process(delta)`.

---

## 2. 2D Top-Down Controller

### GDScript

```gdscript
extends CharacterBody2D

@export var speed: float = 200.0
@export var acceleration: float = 1500.0
@export var friction: float = 1200.0

func _physics_process(delta: float) -> void:
    # 1. Read input (normalized 4-directional vector)
    var input_dir: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")

    # 2 & 3. Apply acceleration or friction to velocity
    if input_dir != Vector2.ZERO:
        velocity = velocity.move_toward(input_dir * speed, acceleration * delta)
    else:
        velocity = velocity.move_toward(Vector2.ZERO, friction * delta)

    # 4. Move and resolve collisions
    move_and_slide()
```

### C#

```csharp
using Godot;

public partial class TopDownPlayer : CharacterBody2D
{
    [Export] public float Speed { get; set; } = 200.0f;
    [Export] public float Acceleration { get; set; } = 1500.0f;
    [Export] public float Friction { get; set; } = 1200.0f;

    public override void _PhysicsProcess(double delta)
    {
        // 1. Read input (normalized 4-directional vector)
        Vector2 inputDir = Input.GetVector("ui_left", "ui_right", "ui_up", "ui_down");

        // 2 & 3. Apply acceleration or friction
        if (inputDir != Vector2.Zero)
            Velocity = Velocity.MoveToward(inputDir * Speed, Acceleration * (float)delta);
        else
            Velocity = Velocity.MoveToward(Vector2.Zero, Friction * (float)delta);

        // 4. Move and resolve collisions
        MoveAndSlide();
    }
}
```

---

## 3. 2D Platformer Controller

### GDScript

```gdscript
extends CharacterBody2D

@export var speed: float = 200.0
@export var jump_velocity: float = -400.0
@export var acceleration: float = 1200.0
@export var deceleration: float = 900.0

# Coyote time and jump buffer
@export var coyote_time: float = 0.12
@export var jump_buffer_time: float = 0.12

var _gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
var _coyote_timer: float = 0.0
var _jump_buffer_timer: float = 0.0
var _was_on_floor: bool = false

func _physics_process(delta: float) -> void:
    # Coyote time: allow jump briefly after walking off a ledge
    if is_on_floor():
        _coyote_timer = coyote_time
        _was_on_floor = true
    else:
        _coyote_timer -= delta

    # Jump buffer: register jump input before landing
    if Input.is_action_just_pressed("ui_accept"):
        _jump_buffer_timer = jump_buffer_time
    else:
        _jump_buffer_timer -= delta

    # Apply gravity when airborne
    if not is_on_floor():
        velocity.y += _gravity * delta

    # Jump: consume coyote time and buffer together
    var can_jump: bool = _coyote_timer > 0.0
    if _jump_buffer_timer > 0.0 and can_jump:
        velocity.y = jump_velocity
        _coyote_timer = 0.0
        _jump_buffer_timer = 0.0

    # Variable jump height: cut velocity when button released early
    if Input.is_action_just_released("ui_accept") and velocity.y < 0.0:
        velocity.y *= 0.5

    # Horizontal movement with deceleration
    var input_x: float = Input.get_axis("ui_left", "ui_right")
    if input_x != 0.0:
        velocity.x = move_toward(velocity.x, input_x * speed, acceleration * delta)
    else:
        velocity.x = move_toward(velocity.x, 0.0, deceleration * delta)

    move_and_slide()
```

### C#

```csharp
using Godot;

public partial class PlatformerPlayer : CharacterBody2D
{
    [Export] public float Speed { get; set; } = 200.0f;
    [Export] public float JumpVelocity { get; set; } = -400.0f;
    [Export] public float Acceleration { get; set; } = 1200.0f;
    [Export] public float Deceleration { get; set; } = 900.0f;
    [Export] public float CoyoteTime { get; set; } = 0.12f;
    [Export] public float JumpBufferTime { get; set; } = 0.12f;

    private float _gravity = ProjectSettings.GetSetting("physics/2d/default_gravity").AsSingle();
    private float _coyoteTimer;
    private float _jumpBufferTimer;

    public override void _PhysicsProcess(double delta)
    {
        float dt = (float)delta;

        // Coyote time
        if (IsOnFloor())
            _coyoteTimer = CoyoteTime;
        else
            _coyoteTimer -= dt;

        // Jump buffer
        if (Input.IsActionJustPressed("ui_accept"))
            _jumpBufferTimer = JumpBufferTime;
        else
            _jumpBufferTimer -= dt;

        // Gravity
        if (!IsOnFloor())
        {
            Vector2 vel = Velocity;
            vel.Y += _gravity * dt;
            Velocity = vel;
        }

        // Jump
        if (_jumpBufferTimer > 0f && _coyoteTimer > 0f)
        {
            Vector2 vel = Velocity;
            vel.Y = JumpVelocity;
            Velocity = vel;
            _coyoteTimer = 0f;
            _jumpBufferTimer = 0f;
        }

        // Variable jump height
        if (Input.IsActionJustReleased("ui_accept") && Velocity.Y < 0f)
        {
            Vector2 vel = Velocity;
            vel.Y *= 0.5f;
            Velocity = vel;
        }

        // Horizontal movement
        float inputX = Input.GetAxis("ui_left", "ui_right");
        Vector2 velocity = Velocity;
        if (inputX != 0f)
            velocity.X = Mathf.MoveToward(velocity.X, inputX * Speed, Acceleration * dt);
        else
            velocity.X = Mathf.MoveToward(velocity.X, 0f, Deceleration * dt);

        Velocity = velocity;
        MoveAndSlide();
    }
}
```

---

## 4. 3D First-Person Controller

### GDScript

```gdscript
extends CharacterBody3D

@export var move_speed: float = 5.0
@export var jump_velocity: float = 5.0
@export var mouse_sensitivity: float = 0.002

@onready var head: Node3D = $Head  # Child Node3D that holds Camera3D

var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")

func _ready() -> void:
    Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
        # Horizontal look: rotate the body (yaw)
        rotate_y(-event.relative.x * mouse_sensitivity)
        # Vertical look: rotate the head (pitch), clamped to ±90°
        head.rotate_x(-event.relative.y * mouse_sensitivity)
        head.rotation.x = clamp(head.rotation.x, -PI / 2.0, PI / 2.0)

    if event.is_action_pressed("ui_cancel"):
        Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

func _physics_process(delta: float) -> void:
    # Gravity
    if not is_on_floor():
        velocity.y -= _gravity * delta

    # Jump
    if Input.is_action_just_pressed("ui_accept") and is_on_floor():
        velocity.y = jump_velocity

    # Movement relative to the direction the player is facing
    var input_dir: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    var direction: Vector3 = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()

    if direction != Vector3.ZERO:
        velocity.x = direction.x * move_speed
        velocity.z = direction.z * move_speed
    else:
        velocity.x = move_toward(velocity.x, 0.0, move_speed)
        velocity.z = move_toward(velocity.z, 0.0, move_speed)

    move_and_slide()
```

### C#

```csharp
using Godot;

public partial class FPSController : CharacterBody3D
{
    [Export] public float MoveSpeed { get; set; } = 5.0f;
    [Export] public float JumpVelocity { get; set; } = 5.0f;
    [Export] public float MouseSensitivity { get; set; } = 0.002f;

    private float _gravity = ProjectSettings.GetSetting("physics/3d/default_gravity").AsSingle();
    private Node3D _head;

    public override void _Ready()
    {
        _head = GetNode<Node3D>("Head");
        Input.MouseMode = Input.MouseModeEnum.Captured;
    }

    public override void _UnhandledInput(InputEvent @event)
    {
        if (@event is InputEventMouseMotion motion
            && Input.MouseMode == Input.MouseModeEnum.Captured)
        {
            // Horizontal look (yaw on body)
            RotateY(-motion.Relative.X * MouseSensitivity);
            // Vertical look (pitch on head), clamped to ±90°
            _head.RotateX(-motion.Relative.Y * MouseSensitivity);
            Vector3 rot = _head.Rotation;
            rot.X = Mathf.Clamp(rot.X, -Mathf.Pi / 2f, Mathf.Pi / 2f);
            _head.Rotation = rot;
        }

        if (@event.IsActionPressed("ui_cancel"))
            Input.MouseMode = Input.MouseModeEnum.Visible;
    }

    public override void _PhysicsProcess(double delta)
    {
        float dt = (float)delta;
        Vector3 vel = Velocity;

        // Gravity
        if (!IsOnFloor())
            vel.Y -= _gravity * dt;

        // Jump
        if (Input.IsActionJustPressed("ui_accept") && IsOnFloor())
            vel.Y = JumpVelocity;

        // Movement relative to facing direction
        Vector2 inputDir = Input.GetVector("ui_left", "ui_right", "ui_up", "ui_down");
        Vector3 direction = (Transform.Basis * new Vector3(inputDir.X, 0, inputDir.Y)).Normalized();

        if (direction != Vector3.Zero)
        {
            vel.X = direction.X * MoveSpeed;
            vel.Z = direction.Z * MoveSpeed;
        }
        else
        {
            vel.X = Mathf.MoveToward(vel.X, 0f, MoveSpeed);
            vel.Z = Mathf.MoveToward(vel.Z, 0f, MoveSpeed);
        }

        Velocity = vel;
        MoveAndSlide();
    }
}
```

---

## 5. Common Movement Recipes

### Dash (GDScript)

```gdscript
extends CharacterBody2D

@export var dash_speed: float = 600.0
@export var dash_duration: float = 0.2

var is_dashing: bool = false
var _dash_timer: float = 0.0
var _dash_direction: Vector2 = Vector2.ZERO

func _physics_process(delta: float) -> void:
    if Input.is_action_just_pressed("dash") and not is_dashing:
        is_dashing = true
        _dash_timer = dash_duration
        # Dash in input direction, or forward if no input
        _dash_direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
        if _dash_direction == Vector2.ZERO:
            _dash_direction = Vector2.RIGHT.rotated(rotation)

    if is_dashing:
        _dash_timer -= delta
        velocity = _dash_direction * dash_speed
        if _dash_timer <= 0.0:
            is_dashing = false

    move_and_slide()
```

### Wall Jump (GDScript)

```gdscript
extends CharacterBody2D

@export var speed: float = 200.0
@export var jump_velocity: float = -400.0
@export var wall_jump_velocity: Vector2 = Vector2(250.0, -350.0)

var _gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta: float) -> void:
    if not is_on_floor():
        velocity.y += _gravity * delta

    # Wall jump: bounce off in the direction of the wall normal
    if Input.is_action_just_pressed("ui_accept"):
        if is_on_floor():
            velocity.y = jump_velocity
        elif is_on_wall():
            var wall_normal: Vector2 = get_wall_normal()
            velocity = wall_normal * wall_jump_velocity.x + Vector2(0, wall_jump_velocity.y)

    var input_x: float = Input.get_axis("ui_left", "ui_right")
    velocity.x = move_toward(velocity.x, input_x * speed, 1000.0 * delta)

    move_and_slide()
```

---

## 6. Common Pitfalls

| Symptom                        | Cause                                        | Fix                                                              |
|-------------------------------|----------------------------------------------|------------------------------------------------------------------|
| Player sticks to walls        | Default wall blocking behavior               | Set `floor_block_on_wall = false` on the CharacterBody           |
| Jittery or frame-rate-dependent movement | Movement in `_process`              | Move all physics/velocity code to `_physics_process(delta)`      |
| Inconsistent jump height      | Fixed velocity ignores frame timing          | Use variable jump (cut `velocity.y` on button release)           |
| Player slides down slopes     | No snap or angle limits                      | Set `floor_snap_length` > 0 and tune `floor_max_angle`           |
| Mouse look inverted           | Wrong sign on rotation delta                 | Negate `event.relative.x` for yaw and/or `event.relative.y` for pitch |

---

## 7. Implementation Checklist

- [ ] All movement logic is inside `_physics_process(delta)`, not `_process`
- [ ] Input action names match exactly what is defined in **Project > Project Settings > Input Map**
- [ ] Gravity value is read from `ProjectSettings` (`physics/2d/default_gravity` or `physics/3d/default_gravity`), not hard-coded
- [ ] `move_and_slide()` is called after all velocity modifications each frame
- [ ] Platformers implement coyote time and jump buffering for responsive feel
- [ ] FPS controllers capture mouse in `_ready()` and release on escape
- [ ] Variable jump height is handled via early-release velocity reduction
