← Back to Dev Logs

Movement That Feels Right

Movement is one of those things that players feel immediately. Get it wrong and everything feels off. Get it right and it's invisible — the player just feels in control. Here's how we built ours.

Why CharacterController?

We chose CharacterController over Rigidbody. It's a trade-off: we lose physics simulation for the player body, but gain precise control over movement feel. For a game where tight controls matter, this was the right call.

The Basic Setup

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float sprintSpeed = 8f;
    [SerializeField] private float rotationSpeed = 720f;
    
    [Header("Physics")]
    [SerializeField] private float gravity = -9.81f;
    [SerializeField] private float jumpHeight = 1.5f;
    
    private CharacterController controller;
    private Vector3 velocity;
    private bool isGrounded;
    
    void Start()
    {
        controller = GetComponent();
    }
    
    void Update()
    {
        HandleGroundCheck();
        HandleMovement();
        HandleGravity();
    }
    
    void HandleGroundCheck()
    {
        // Small sphere cast for ground detection
        isGrounded = Physics.CheckSphere(
            transform.position - Vector3.up * 0.1f, 
            0.4f, 
            LayerMask.GetMask("Ground")
        );
        
        if (isGrounded && velocity.y < 0)
        {
            velocity.y = -2f; // Small downward force when grounded
        }
    }
    
    void HandleMovement()
    {
        float horizontal = Input.GetAxis("Horizontal");
        float vertical = Input.GetAxis("Vertical");
        
        Vector3 movement = new Vector3(horizontal, 0f, vertical).normalized;
        
        if (movement.magnitude >= 0.1f)
        {
            // Calculate facing direction
            float targetAngle = Mathf.Atan2(movement.x, movement.z) * 
                               Mathf.Rad2Deg + Camera.main.transform.eulerAngles.y;
            
            float angle = Mathf.SmoothDampAngle(
                transform.eulerAngles.y, 
                targetAngle, 
                ref rotationSpeed, 
                0.1f
            );
            
            transform.rotation = Quaternion.Euler(0f, angle, 0f);
            
            // Move the player
            Vector3 moveDir = Quaternion.Euler(0f, targetAngle, 0f) * Vector3.forward;
            controller.Move(moveDir.normalized * moveSpeed * Time.deltaTime);
        }
    }
    
    void HandleGravity()
    {
        velocity.y += gravity * Time.deltaTime;
        controller.Move(velocity * Time.deltaTime);
    }
}

Smooth Acceleration

003cp>Instant acceleration feels robotic. We use Mathf.SmoothDamp for velocity smoothing:

[SerializeField] private float acceleration = 10f;
private Vector3 currentVelocity;

void HandleMovement()
{
    float horizontal = Input.GetAxis("Horizontal");
    float vertical = Input.GetAxis("Vertical");
    
    Vector3 targetMovement = new Vector3(horizontal, 0f, vertical).normalized;
    
    // Smooth acceleration
    currentVelocity = Vector3.SmoothDamp(
        currentVelocity, 
        targetMovement * moveSpeed, 
        ref velocityRef, 
        1f / acceleration
    );
    
    controller.Move(currentVelocity * Time.deltaTime);
}

Camera-Relative Movement

The player should move relative to where they're looking, not the world. The camera angle adjustment in HandleMovement() handles this automatically.

Next Steps

This is the foundation. Coming up: sprinting, crouching, and raycasting for interaction.

← Player Raycasting Crosshair Design →