How to Create a 2D Character Controller in Unity Without a Rigidbody

 

How to Create a 2D Character Controller in Unity Without a Rigidbody

For many 2D games, especially platformers that require crisp, predictable movement, relying on Unity's built-in physics engine via the Rigidbody2D component can sometimes feel like overkill or introduce unwanted physical behaviors. A kinematic or custom physics approach, where you directly control the character's position, offers a higher degree of precision.

This tutorial will guide you through creating a responsive 2D character controller from scratch in Unity using C#, without using a Rigidbody2D. We will handle movement and collisions manually by casting rays to detect our surroundings, giving us complete control over how the character interacts with the world.

Project Setup: The Basics

Before we dive into the code, let's set up a basic scene to test our controller.

  1. Cr[ Open Unity Hub and create a new project using the "2D (Core)" templ[2][3]ate.

  2. Create the Player:

    • In the Hierarchy, right-click and s[3][4]elect "2D Object" > "Sprite" > "Square".

    • Rename this GameObject to "Player".

    • To give it a distinct color[4][5], you can create a new material, set its color, and assign it, or simply use the Color property in the Sprite Renderer component.

    • In the Inspe[1][5]ctor, add a new component by clicking "Add Component" and searching for "Box Collider 2D". This will represent our player's physical boundaries.


  3. Create the Ground:

    • Create another "Square" Sprite in the Hierarchy and name it "G[4][5]round".

    • Use the Scale tool (or adjust the Scale in its Transform component) to stretch it horizontally, forming a floor for the player to stand on.

    • Add a "Box Collider 2D" to the Ground as well.


  4. Set Up Layers for Collision: We need to tell our controller what is considered "ground." Using layers is the most efficient way to do this.

    • Select the "Ground" GameObject.
      *[4] In the Inspector, click on the "Layer" dropdown (currently set to "Default") and sel[4][6]ect "Add Layer...".

    • In the first available User Layer (e.g., Layer 6), type "Ground" and press Enter.

    • Reselect the "Ground" GameObject and change its Layer to[4][7] the newly created "Ground" layer.


Your scene should now have a "Player" and a "Ground" object, both with Box Collid[4]er 2D components, and the ground should be on its own layer.

The Character Controller Script

Now it's time to write the code.

  1. Select your "Player" GameObje[6]ct.

  2. Click "Add Component" in the Inspector, type "PlayerController" into the search box, and select "New Script".

  3. Click "Create and Add". This will create a new C# script named PlayerController and attach it to your player.

  4. Double-click the script to open i[6][8][9]t in your code editor (like Visual Studio or VS Code).

Replace the default code w[8]ith the following. We will break down what each part does afterward.

C#

using UnityEngine;


public class PlayerController : MonoBehaviour

{

    [Header("Movement Settings")]

    public float moveSpeed = 5f;

    public float jumpHeight = 4f;


    [Header("Collision Settings")]

    public LayerMask groundLayer;

    public float groundCheckDistance = 0.1f;

    public float skinWidth = 0.015f;


    private float gravity = -9.81f;

    private Vector2 velocity;

    private BoxCollider2D boxCollider;

    private bool isGrounded;


    void Start()

    {

        boxCollider = GetComponent<BoxCollider2D>();

    }


    void Update()

    {

        // Ground Check

        isGrounded = CheckGrounded();


        // Reset vertical velocity if grounded

        if (isGrounded && velocity.y < 0)

        {

            velocity.y = 0;

        }


        // Horizontal Movement

        float horizontalInput = Input.GetAxisRaw("Horizontal");

        velocity.x = horizontalInput * moveSpeed;


        // Jumping

        if (Input.GetButtonDown("Jump") && isGrounded)

        {

            // The formula for jump velocity is: sqrt(-2 * gravity * jumpHeight)

            velocity.y = Mathf.Sqrt(-2f * gravity * jumpHeight);

        }


        // Apply Gravity

        velocity.y += gravity * Time.deltaTime;


        // Apply final movement

        Move(velocity * Time.deltaTime);

    }


    private bool CheckGrounded()

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2); // Inset the bounds to avoid self-collision


        // Cast a ray downwards from the bottom of the collider

        RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y + groundCheckDistance, groundLayer);


        // For debugging, draw the ray in the scene view

        Color rayColor = hit.collider != null ? Color.green : Color.red;

        Debug.DrawRay(bounds.center, Vector2.down * (bounds.extents.y + groundCheckDistance), rayColor);


        return hit.collider != null;

    }


    private void Move(Vector2 moveAmount)

    {

        // We need to check for collisions before moving

        // This is a simplified version; a full implementation would handle horizontal collisions too

        HandleCollisions(ref moveAmount);

        transform.Translate(moveAmount);

    }


    private void HandleCollisions(ref Vector2 moveAmount)

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2);


        // Vertical Collision Detection

        if (moveAmount.y < 0)

        {

            RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y - moveAmount.y, groundLayer);

            if (hit.collider != null)

            {

                // If we hit something below, adjust our movement amount

                moveAmount.y = -hit.distance + bounds.extents.y;

            }

        }

        

        // A full controller would also have horizontal and upward collision checks here

    }

}

Understanding the Code

Variables

  • moveSpeed & jumpHeight: Public variables you can tweak in the Inspector to change how the character feels.

  • groundLayer: This is crucial. We will assign our "Ground" layer here so our raycasts only detect collidable ground and not other objects like enemies or triggers.

  • groundCheckDistance: A small extra distance for the ground check raycast to ensure it reliably detects the ground.

  • skinWidth: A very small offset to prevent the character from getting stuck inside other colliders.

  • gravity: A private variable to simulate gravity. We manually apply it since we aren't using Unity's physics engine.

  • velocity: A Vector2 that stores our character's current speed and direction for this frame.

  • boxCollider: A reference to our player's BoxCollider2D component to get its size and position for raycasting.

  • isGrounded: A boolean that tracks whether the player is currently on the ground.

Start() Method

  • We get and store a reference to the BoxCollider2D component. This is more efficient th[15][16]an calling GetComponent<>() every frame.

Update() Method

This is where the main logic happens every frame.

  1. Ground Check: We first call our CheckGrounded() function to determine if the player is standing on a valid surface.

  2. Reset Gravity: If we are grounded and still have some downward velocity from the previous frame, we reset velocity.y to 0. This prevents the character fro[11][17]m accumulating downward speed while on the ground, which could cause it to fall through thin platforms.

  3. Horizontal Input: Input.GetAxisRaw("Horizontal") gets the player's input from the A/D keys or a controller's analog stick. It returns -1 for left, 1 for right, and 0 for no input. We multiply this by moveSpeed to get our horizontal velocity.

  4. Jumping: If the "Jump" button (space bar by default) is pressed and the player is grounded, we calculate the required upward velocity to reach our desired jumpHeight. The formula Mathf.Sqrt(-2f * gravity * jumpHeight) is derived from physics equations and gives us the perfect initial velocity for the jump.

  5. Apply Gravity: We constantly add the force of gravity to our vertical velocity. Time.deltaTime ensures that the gravity application is smooth and independent of the frame rate.

  6. Final Move: We call our custom Move() function, passing in the calculated velocity multiplied by Time.deltaTime to get the amount we should move this frame.

CheckGrounded() Method

This function is the core of our collision detection.

  • It gets the player collider's bounds, which is an axis-aligned bounding box surrounding the collider.

  • We then perform a Physics2D.Raycast. This is like shooting a tiny, invisible laser from a starting point, in a certain direction, with a maximum length.

  • bounds.center: The ray star[9][14]ts from the center of our collider.

  • Vector2.down: The ray shoots straight down.
    *[14] bounds.extents.y + groundCheckDistance: The length of the ray is half the collider's height plus our small buffer distance.[13]

  • groundLayer: The ray will only detect objects on this layer.

  • The function returns true if the ray hits a collider on the specified layer, [13]and false otherwise.

Move() and HandleCollisions() Methods

The Move() method is simple: it calls HandleCollisions() and then applies the final, adjusted movement using transform.Translate().

The HandleCollisions() function is a si[18][19]mplified example of how you would prevent the character from passing through objects. It casts a ray in the direction of movement. If it detects a collision before the full movement is applied, it adjusts moveAmount to stop precisely at the surface of the object it hit.

Note: The provided HandleCollisions only checks downwards. A complete character controller would also need to cast rays horizontally (left and right) and upwards to handle wall and ceiling collisions.

Final Setup in the Unity Editor

  1. Go back to the Unity Editor and select your "Player" GameObject.

  2. In the Inspector, you will see the "Player Controller (Script)" component with its public variables.

  3. Assign the Ground Layer: Click the "None (Layer Mask)" dropdown next to the "Ground Layer" field and select your "Ground" layer.

  4. Adjust the Move Speed, Jump Height, and othe[8][9][20]g "Add Component" and searching for "Box Collider 2D". This will represent our player's physical boundaries.

  5. Create the Ground:

    • Create another "Square" Sprite in the Hierarchy and name it "Ground".

    • Use the Scale tool (or adjust the Scale in its Transform component) to stretch it horizontally, forming a floor for the player to stand on.

    • Add a "Box Collider 2D" to the Ground as well.


  6. Set Up Layers for Collision: We need to tell our controller what is considered "ground." Using layers is the most efficient way to do this.

    • Select the "Ground" GameObject.

    • In the Inspector, click on the "Layer" dropdown (currently set to "Default") and select "Add Layer...".

    • In the first available User Layer (e.g., Layer 6), type "Ground" and press Enter.

    • Reselect the "Ground" GameObject and change its Layer to the newly created "Ground" layer.


Your scene should now have a "Player" and a "Ground" object, both with Box Collider 2D components, and the ground should be on its own layer.

The Character Controller Script

Now it's time to write the code.

  1. Select your "Player" GameObject.

  2. Click "Add Component" in the Inspector, type "PlayerController" into the search box, and select "New Script".

  3. Click "Create and Add". This will create a new C# script named PlayerController and attach it to your player.

  4. Double-click the script to open it in your code editor (like Visual Studio or VS Code).

Replace the default code with the following. We will break down what each part does afterward.

C#

using UnityEngine;


public class PlayerController : MonoBehaviour

{

    [Header("Movement Settings")]

    public float moveSpeed = 5f;

    public float jumpHeight = 4f;


    [Header("Collision Settings")]

    public LayerMask groundLayer;

    public float groundCheckDistance = 0.1f;

    public float skinWidth = 0.015f;


    private float gravity = -9.81f;

    private Vector2 velocity;

    private BoxCollider2D boxCollider;

    private bool isGrounded;


    void Start()

    {

        boxCollider = GetComponent<BoxCollider2D>();

    }


    void Update()

    {

        // Ground Check

        isGrounded = CheckGrounded();


        // Reset vertical velocity if grounded

        if (isGrounded && velocity.y < 0)

        {

            velocity.y = 0;

        }


        // Horizontal Movement

        float horizontalInput = Input.GetAxisRaw("Horizontal");

        velocity.x = horizontalInput * moveSpeed;


        // Jumping

        if (Input.GetButtonDown("Jump") && isGrounded)

        {

            // The formula for jump velocity is: sqrt(-2 * gravity * jumpHeight)

            velocity.y = Mathf.Sqrt(-2f * gravity * jumpHeight);

        }


        // Apply Gravity

        velocity.y += gravity * Time.deltaTime;


        // Apply final movement

        Move(velocity * Time.deltaTime);

    }


    private bool CheckGrounded()

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2); // Inset the bounds to avoid self-collision


        // Cast a ray downwards from the bottom of the collider

        RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y + groundCheckDistance, groundLayer);


        // For debugging, draw the ray in the scene view

        Color rayColor = hit.collider != null ? Color.green : Color.red;

        Debug.DrawRay(bounds.center, Vector2.down * (bounds.extents.y + groundCheckDistance), rayColor);


        return hit.collider != null;

    }


    private void Move(Vector2 moveAmount)

    {

        // We need to check for collisions before moving

        // This is a simplified version; a full implementation would handle horizontal collisions too

        HandleCollisions(ref moveAmount);

        transform.Translate(moveAmount);

    }


    private void HandleCollisions(ref Vector2 moveAmount)

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2);


        // Vertical Collision Detection

        if (moveAmount.y < 0)

        {

            RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y - moveAmount.y, groundLayer);

            if (hit.collider != null)

            {

                // If we hit something below, adjust our movement amount

                moveAmount.y = -hit.distance + bounds.extents.y;

            }

        }

        

        // A full controller would also have horizontal and upward collision checks here

    }

}

Understanding the Code

Variables

  • moveSpeed & jumpHeight: Public variables you can tweak in the Inspector to change how the character feels.

  • groundLayer: This is crucial. We will assign our "Ground" layer here so our raycasts only detect collidable ground and not other objects like enemies or triggers.

  • groundCheckDistance: A small extra distance for the ground check raycast to ensure it reliably detects the ground.

  • skinWidth: A very small offset to prevent the character from getting stuck inside other colliders.

  • gravity: A private variable to simulate gravity. We manually apply it since we aren't using Unity's physics engine.

  • velocity: A Vector2 that stores our character's current speed and direction for this frame.

  • boxCollider: A reference to our player's BoxCollider2D component to get its size and position for raycasting.

  • isGrounded: A boolean that tracks whether the player is currently on the ground.

Start() Method

  • We get and store a reference to the BoxCollider2D component. This is more efficient than calling GetComponent<>() every frame.

Update() Method

This is where the main logic happens every frame.

  1. Ground Check: We first call our CheckGrounded() function to determine if the player is standing on a valid surface.

  2. Reset Gravity: If we are grounded and still have some downward velocity from the previous frame, we reset velocity.y to 0. This prevents the character from accumulating downward speed while on the ground, which could cause it to fall through thin platforms.

  3. Horizontal Input: Input.GetAxisRaw("Horizontal") gets the player's input from the A/D keys or a controller's analog stick. It returns -1 for left, 1 for right, and 0 for no input. We multiply this by moveSpeed to get our horizontal velocity.

  4. Jumping: If the "Jump" button (space bar by default) is pressed and the player is grounded, we calculate the required upward velocity to reach our desired jumpHeight. The formula Mathf.Sqrt(-2f * gravity * jumpHeight) is derived from physics equations and gives us the perfect initial velocity for the jump.

  5. Apply Gravity: We constantly add the force of gravity to our vertical velocity. Time.deltaTime ensures that the gravity application is smooth and independent of the frame rate.

  6. Final Move: We call our custom Move() function, passing in the calculated velocity multiplied by Time.deltaTime to get the amount we should move this frame.

CheckGrounded() Method

This function is the core of our collision detection.

  • It gets the player collider's bounds, which is an axis-aligned bounding box surrounding the collider.

  • We then perform a Physics2D.Raycast. This is like shooting a tiny, invisible laser from a starting point, in a certain direction, with a maximum length.

  • bounds.center: The ray starts from the center of our collider.

  • Vector2.down: The ray shoots straight down.

  • bounds.extents.y + groundCheckDistance: The length of the ray is half the collider's height plus our small buffer distance.

  • groundLayer: The ray will only detect objects on this layer.

  • The function returns true if the ray hits a collider on the specified layer, and false otherwise.

Move() and HandleCollisions() Methods

The Move() method is simple: it calls HandleCollisions() and then applies the final, adjusted movement using transform.Translate().

The HandleCollisions() function is a simplified example of how you would prevent the character from passing through objects. It casts a ray in the direction of movement. If it detects a collision before the full movement is applied, it adjusts moveAmount to stop precisely at the surface of the object it hit.

Note: The provided HandleCollisions only checks downwards. A complete character controller would also need to cast rays horizontally (left and right) and upwards to handle wall and ceiling collisions.

Final Setup in the Unity Editor

  1. Go back to the Unity Editor and select your "Player" GameObject.

  2. In the Inspector, you will see the "Player Controller (Script)" component with its public variables.

  3. Assign the Ground Layer: Click the "None (Layer Mask)" dropdown next to the "Ground Layer" field and select your "Ground" layer.

  4. Adjust the Move Speed, Jump Height, and othe[1][2]g "Add Component" and searching for "Box Collider 2D". This will represent our player's physical boundaries.

  5. Create the Ground:

    • Create another "Square" Sprite in the Hierarchy and name it "Ground".

    • Use the Scale t[1][3]ool (or adjust the Scale in its Transform component) to stretch it horizontally, forming a floor for the player to stand on.

    • Add a "Box Collider 2D" to the Ground as well.


  6. Set Up Layers for Collision: We n[4]eed to tell our controller what is considered "ground." Using layers is the most efficient way to do this.

    • Select t[1]he "Ground" GameObject.

    • In the Inspector, click on the "Layer" dropdown (currently set to "Default") and select "Add Layer...".

    • In the first available User Layer (e.g., La[3]yer 6), type "Ground" and press Enter.

    • Reselect the "Ground" GameObject and change its Layer to the newly created "Ground" layer.


Your scene should now have a "Player" and a "Ground" object, both with Box Collider 2D components, and the ground should be on its own layer.

The Character Controller Script

Now it's time to write the code.

1[5][6]. Select your "Player" GameObject.
2. Click "Add Component" in the Inspector, [3]type "PlayerController" into the search box, and select "New Script".
3. Click "Create and Add". This will create a new C# scr[7]ipt named PlayerController and attach it to your player.
4. Double-click the script to open[4][7] it in your code editor (like Visual Studio or VS Code).

Replace the default code with the following. We will break down what each part does afterward.

C#

using UnityEngine;


public class PlayerController : MonoBehaviour

{

    [Header("Movement Settings")]

    public float moveSpeed = 5f;

    public float jumpHeight = 4f;


    [Header("Collision Settings")]

    public LayerMask groundLayer;

    public float groundCheckDistance = 0.1f;

    public float skinWidth = 0.015f;


    private float gravity = -9.81f;

    private Vector2 velocity;

    private BoxCollider2D boxCollider;

    private bool isGrounded;


    void Start()

    {

        boxCollider = GetComponent<BoxCollider2D>();

    }


    void Update()

    {

        // Ground Check

        isGrounded = CheckGrounded();


        // Reset vertical velocity if grounded

        if (isGrounded && velocity.y < 0)

        {

            velocity.y = 0;

        }


        // Horizontal Movement

        float horizontalInput = Input.GetAxisRaw("Horizontal");

        velocity.x = horizontalInput * moveSpeed;


        // Jumping

        if (Input.GetButtonDown("Jump") && isGrounded)

        {

            // The formula for jump velocity is: sqrt(-2 * gravity * jumpHeight)

            velocity.y = Mathf.Sqrt(-2f * gravity * jumpHeight);

        }


        // Apply Gravity

        velocity.y += gravity * Time.deltaTime;


        // Apply final movement

        Move(velocity * Time.deltaTime);

    }


    private bool CheckGrounded()

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2); // Inset the bounds to avoid self-collision


        // Cast a ray downwards from the bottom of the collider

        RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y + groundCheckDistance, groundLayer);


        // For debugging, draw the ray in the scene view

        Color rayColor = hit.collider != null ? Color.green : Color.red;

        Debug.DrawRay(bounds.center, Vector2.down * (bounds.extents.y + groundCheckDistance), rayColor);


        return hit.collider != null;

    }


    private void Move(Vector2 moveAmount)

    {

        // We need to check for collisions before moving

        // This is a simplified version; a full implementation would handle horizontal collisions too

        HandleCollisions(ref moveAmount);

        transform.Translate(moveAmount);

    }


    private void HandleCollisions(ref Vector2 moveAmount)

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2);


        // Vertical Collision Detection

        if (moveAmount.y < 0)

        {

            RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y - moveAmount.y, groundLayer);

            if (hit.collider != null)

            {

                // If we hit something below, adjust our movement amount

                moveAmount.y = -hit.distance + bounds.extents.y;

            }

        }

        

        // A full controller would also have horizontal and upward collision checks here

    }

}

Un[12]derstanding the Code

Variables

  • moveSpeed & jumpHeight: Public variables you can tweak in the Inspec[13]tor to change how the character feels.

  • groundLayer: This is crucial. We will assign our "Ground" layer here so our raycasts only detect collidable ground and not other objects like enemies or triggers.

  • groundCheckDistance: A small extra distance for the ground check raycast to ensure it reliably detects the ground.

  • skinWidth: A very small offset to prevent the character from getting stuck inside other colliders.

  • gravity: A private variable to simulate gravity. We manually apply it since we aren't using Unity's physics engine.

  • velocity: A Vector2 that stores our character's current speed and direction for this frame.

  • boxCollider: A reference to our [14]player's BoxCollider2D component to get its size and position for raycasting.

  • isGrounded: A boolean that tracks whether the player is currently on the ground.

Start() Method

  • We get and store a reference to the BoxCollider2D component. This is more efficient than calling GetComponent<>() every frame.

Update() Method

This is where the main logic happens every frame.

  1. Ground Check: We first call our CheckGrounded() function to determine if the player is standing on a valid surface.

  2. Reset Gravity: If we are grounded and still have some downward velocity from the previous frame, we reset v[[15](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQFCw6eT_eeBEacAp6B8fcB9r8474yT4A0Eg4nncBbey67Eqi3QBY5vE_88PvmrBIMb0P3fn_y2lcuFhIYE1LAF5NFYvSTlVw-b-27tLUdTzq_5JmAC04vn_ofCK27eQ4j2zTVct_g%3D%3D)][[16](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQGUvbmQKbrJVtQtFATGeNgb-vnvZA7ipkKMDi4KEuykISdOb7-L-lCK9PoaJVtUGNxno3pog_xx-bMcEHzVV9t9sDXQ1uSMQozpXezQ4WGiaSWUiJE-crNtzy9v79EmBcqLW-_ytg%3D%3D)]elocity.y to 0. This prevents the character from accumulating downward speed while on the ground, which could cause it to fall thro[17]ugh thin platforms.

  3. Horizontal Input: Input.GetAxisRaw("Horizontal") gets the player's input from the A/D keys or a controller's analog stick. It returns -1 for left, 1 for right, an[18]d 0 for no input. We multiply this by moveSpeed to get our horizontal velocity.

  4. Jumping: If the "Jump" button (space bar by default) is pressed and the player is grounded, we calculate the required upward velocity to reach our desired jumpHeight. The formula Mathf.Sqrt(-2f * gravity * jumpHeight) is derived from physics equations and gives us the perfect initial velocity for the jump.

  5. Apply Gravity: We constantly add the force of gravity to our vertical velocity. Time.deltaTime ensures that the gravity application is smooth and independent of the frame rate.

  6. Final Move: We call our custom Move() function, passing in the calculated velocity multiplied by Time.deltaTime to get the amount we should move this frame.

CheckGrounded() Method

This function is the core of our collision detection.

  • It gets the player collider's bounds, which is an axis-aligned bounding box surrounding the collider.

  • We then perform a Physics2D.Raycast. This is like shooting a tiny, invisible laser from a starting point, in a certain direction, with a maximum length.

  • bounds.center: The ray starts from the center of our collider.

  • Vector2.down: The ray shoots straight down.

  • bounds.extents.y + groundCheckDistance: The length of the ray is half the collider's height plus our small buffer distance.

  • groundLayer: The ray will only detect objects on this layer.

  • The function returns true if the ray hits a collider on the specified layer, and false otherwise.

Move() and HandleCollisions() Methods

The Move() method is simple: it calls HandleCollisions() and then applies the final, adjusted movement using transform.Translate().

The HandleCollisions() function is a simplified example of how you would prevent the character from passing through objects. It casts a ray in the direction of movement. If it detects a collision before the full movement is [19]applied, it adjusts moveAmount to stop precisely at the surface of the object it hit.

Note: The provided HandleCollisions only checks downwards. A[20] complete character controller would also need to cast rays horizontally (left and right) and upwards to handle wall and ceiling collisions.

Final Setup in the Unity Editor

  1. Go back to the Unity Editor and select your "Player" GameObject.

  2. In the Inspector, you will see the "Player Controller (Script)" component with its public variables.

  3. Assign the Ground Layer: Click the "None (Layer Mask)" dropdown next to the "Ground Layer" field and s[21] our surroundings, giving us complete control over how the character interacts with the world.

Project Setup: The Basics

Before we dive into the code, let's set up a basic scene to test our controller.

  1. Create a New 2D Project: Open Unity Hub and create a new project using the "2D (Core)" template.

  2. Create the Player:

    • In the Hierarchy, right-click and select "2D Object" > "Sprite" > "Square".

    • Rename this GameObject to "Player".

    • To give it a distinct color, you can create a new material, set its color, and assign it, or simply use the Color property in the Sprite Renderer component.

    • In the Inspector, add a new component by clicking "Add Component" and searching for "Box Collider 2D". This will represent our player's physical boundaries.


  3. Create the Ground:

    • Create another "Square" Sprite in the Hierarchy and name it "Ground".

    • Use the Scale tool (or adjust the Scale in its Transform component) to stretch it horizontally, forming a floor for the player to stand on.

    • Add a "Box Collider 2D" to the Ground as well.


  4. Set Up Layers for Collision: We need to tell our controller what is considered "ground." Using layers is the most efficient way to do this.

    • Select the "Ground" GameObject.

    • In the Inspector, click on the "Layer" dropdown (currently set to "Default") and select "Add Layer...".

    • In the first available User Layer (e.g., Layer 6), type "Ground" and press Enter.

    • Reselect the "Ground" GameObject and change its Layer to the newly created "Ground" layer.


Your scene should now have a "Player" and a "Ground" object, both with Box Collider 2D components, and the ground should be on its own layer.

The Character Controller Script

Now it's time to write the code.

  1. Select your "Player" GameObject.

  2. Click "Add Component" in the Inspector, type "PlayerController" into the search box, and select "New Script".

  3. Click "Create and Add". This will create a new C# script named PlayerController and attach it to your player.

  4. Double-click the script to open it in your code editor (like Visual Studio or VS Code).

Replace the default code with the following. We will break down what each part does afterward.

C#

using UnityEngine;


public class PlayerController : MonoBehaviour

{

    [Header("Movement Settings")]

    public float moveSpeed = 5f;

    public float jumpHeight = 4f;


    [Header("Collision Settings")]

    public LayerMask groundLayer;

    public float groundCheckDistance = 0.1f;

    public float skinWidth = 0.015f;


    private float gravity = -9.81f;

    private Vector2 velocity;

    private BoxCollider2D boxCollider;

    private bool isGrounded;


    void Start()

    {

        boxCollider = GetComponent<BoxCollider2D>();

    }


    void Update()

    {

        // Ground Check

        isGrounded = CheckGrounded();


        // Reset vertical velocity if grounded

        if (isGrounded && velocity.y < 0)

        {

            velocity.y = 0;

        }


        // Horizontal Movement

        float horizontalInput = Input.GetAxisRaw("Horizontal");

        velocity.x = horizontalInput * moveSpeed;


        // Jumping

        if (Input.GetButtonDown("Jump") && isGrounded)

        {

            // The formula for jump velocity is: sqrt(-2 * gravity * jumpHeight)

            velocity.y = Mathf.Sqrt(-2f * gravity * jumpHeight);

        }


        // Apply Gravity

        velocity.y += gravity * Time.deltaTime;


        // Apply final movement

        Move(velocity * Time.deltaTime);

    }


    private bool CheckGrounded()

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2); // Inset the bounds to avoid self-collision


        // Cast a ray downwards from the bottom of the collider

        RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y + groundCheckDistance, groundLayer);


        // For debugging, draw the ray in the scene view

        Color rayColor = hit.collider != null ? Color.green : Color.red;

        Debug.DrawRay(bounds.center, Vector2.down * (bounds.extents.y + groundCheckDistance), rayColor);


        return hit.collider != null;

    }


    private void Move(Vector2 moveAmount)

    {

        // We need to check for collisions before moving

        // This is a simplified version; a full implementation would handle horizontal collisions too

        HandleCollisions(ref moveAmount);

        transform.Translate(moveAmount);

    }


    private void HandleCollisions(ref Vector2 moveAmount)

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2);


        // Vertical Collision Detection

        if (moveAmount.y < 0)

        {

            RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y - moveAmount.y, groundLayer);

            if (hit.collider != null)

            {

                // If we hit something below, adjust our movement amount

                moveAmount.y = -hit.distance + bounds.extents.y;

            }

        }

        

        // A full controller would also have horizontal and upward collision checks here

    }

}

Understanding the Code

Variables

  • moveSpeed & jumpHeight: Public variables you can tweak in the Inspector to change how the character feels.

  • groundLayer: This is crucial. We will assign our "Ground" layer here so our raycasts only detect collidable ground and not other objects like enemies or triggers.

  • groundCheckDistance: A small extra distance for the ground check raycast to ensure it reliably detects the ground.

  • skinWidth: A very small offset to prevent the character from getting stuck inside other colliders.

  • gravity: A private variable to simulate gravity. We manually apply it since we aren't using Unity's physics engine.

  • velocity: A Vector2 that stores our character's current speed and direction for this frame.

  • boxCollider: A reference to our player's BoxCollider2D component to get its size and position for raycasting.

  • isGrounded: A boolean that tracks whether the player is currently on the ground.

Start() Method

  • We get and store a reference to the BoxCollider2D component. This is more efficient than calling GetComponent<>() every frame.

Update() Method

This is where the main logic happens every frame.

  1. Ground Check: We first call our CheckGrounded() function to determine if the player is standing on a valid surface.

  2. Reset Gravity: If we are grounded and still have some downward velocity from the previous frame, we reset velocity.y to 0. This prevents the character from accumulating downward speed while on the ground, which could cause it to fall through thin platforms.

  3. Horizontal Input: Input.GetAxisRaw("Horizontal") gets the player's input from the A/D keys or a controller's analog stick. It returns -1 for left, 1 for right, and 0 for no input. We multiply this by moveSpeed to get our horizontal velocity.

  4. Jumping: If the "Jump" button (space bar by default) is pressed and the player is grounded, we calculate the required upward velocity to reach our desired jumpHeight. The formula Mathf.Sqrt(-2f * gravity * jumpHeight) is derived from physics equations and gives us the perfect initial velocity for the jump.

  5. Apply Gravity: We constantly add the force of gravity to our vertical velocity. Time.deltaTime ensures that the gravity application is smooth and independent of the frame rate.

  6. Final Move: We call our custom Move() function, passing in the calculated velocity multiplied by Time.deltaTime to get the amount we should move this frame.

CheckGrounded() Method

This function is the core of our collision detection.

  • It gets the player collider's bounds, which is an axis-aligned bounding box surrounding the collider.

  • We then perform a Physics2D.Raycast. This is like shooting a tiny, invisible laser from a starting point, in a certain direction, with a maximum length.

  • bounds.center: The ray starts from the center of our collider.

  • Vector2.down: The ray shoots straight down.

  • bounds.extents.y + groundCheckDistance: The length of the ray is half the collider's height plus our small buffer distance.

  • groundLayer: The ray will only detect objects on this layer.

  • The function returns true if the ray hits a collider on the specified layer, and false otherwise.

Move() and HandleCollisions() Methods

The Move() method is simple: it calls HandleCollisions() and then applies the final, adjusted movement using transform.Translate().

The HandleCollisions() function is a simplified example of how you would prevent the character from passing through objects. It casts a ray in the direction of movement. If it detects a collision before the full movement is applied, it adjusts moveAmount to stop precisely at the surface of the object it hit.

Note: The provided HandleCollisions only checks downwards. A complete character controller would also need to cast rays horizontally (left and right) and upwards to handle wall and ceiling collisions.

Final Setup in the Unity Editor

  1. Go back to the Unity Editor and select your "Player" GameObject.

  2. In the Inspector, you will see the "Player Controller (Script)" component with its public variables.

  3. Assign the Ground Layer: Click the "None (Layer Mask)" dropdown next to the "Ground Layer" field and s[1][2] our surroundings, giving us complete control over how [2][3][4][5]the character interacts with the world.

Project Setup: The Basics

Before we dive into the code, let's set up a basic scene to test our co[1][2][3]ntroller.

  1. Create a New 2D Project: Open Unity Hub and create a new project using the [1][2][3]"2D (Core)" template.

  2. Create the Player:

    • In the Hierarchy, right-click and select "2D Object" > "Sprite" > "Square".

    • Rename this GameObject to[2][3][5] "Player".

    • To give it a distinct color, you can create a new material, set its color, and assign it, or simply use the Color property in the Sprite Renderer component.

    • In[6] the Inspector, add a new component by clicking "Add Component" and searching for "Box Collider 2D". This will represent our player's physical boundaries.


  3. Create the Ground:

    • Create another "Square" Sprite in t[1][2][4]he Hierarchy and name it "Ground".

    • Use the Scale tool (or adjust the Scale in[3][4] its Transform component) to stretch it horizontally, forming a floor for the player to [3]stand on.

    • Add a "Box Collider 2D" to the Ground as well.


  4. [3]Set Up Layers for Collision: We need to tell our controller what is considered "ground." Using layers is the most efficient way to do this.

    • Select the "Ground" GameObject.
      [7] * In the Inspector, click on the "Layer" dropdown (currently set[3] to "Default") and select "Add Layer...".

    • In the first available User Layer (e.g., Layer 6), type "Ground" and press Enter.
      [5][8] * Reselect the "Ground" GameObject and change its Layer to the newly created "Ground" layer.


Your scene should now have a "Player"[5][7][9] and a "Ground" object, both with Box Collider 2D components, and the ground should be on its own layer.

The Character Contr[8]oller Script

Now it's time to write the code.

  1. Select your "Player" GameObject.

  2. Click "Add Component[5][7]" in the Inspector, type "PlayerController" into the search box, and select "New Script".

  3. Click "Create and Add". This will create a new C# script named PlayerController and attach it to your player.

  4. Double-click the script to open it in your code editor (like Visual Studio or VS Code).

R[6]eplace the default code with the following. We will break down wh[9]at each part does afterward.

C#

using UnityEngine;

public class PlayerController : MonoBehaviour

{

    [Header("Movement Settings")]

    public float moveSpeed = 5f;

    public float jumpHeight = 4f;

    [Header("Collision Settings")]

    public LayerMask groundLayer;

    public float groundCheckDistance = 0.1f;

    public float skinWidth = 0.015f;

    private float gravity = -9.81f;

    private Vector2 velocity;

    private BoxCollider2D boxCollider;

    private bool isGrounded;

    void Start()

    {

        boxCollider = GetComponent<BoxCollider2D>();

    }

    void Update()

    {

        // Ground Check

        isGrounded = CheckGrounded();

        // Reset vertical velocity if grounded

        if (isGrounded && velocity.y < 0)

        {

            velocity.y = 0;

        }

        // Horizontal Movement

        float horizontalInput = Input.GetAxisRaw("Horizontal");

        velocity.x = horizontalInput * moveSpeed;

        // Jumping

        if (Input.GetButtonDown("Jump") && isGrounded)

        {

            // The formula for jump velocity is: sqrt(-2 * gravity * jumpHeight)

            velocity.y = Mathf.Sqrt(-2f * gravity * jumpHeight);

        }

        // Apply Gravity

        velocity.y += gravity * Time.deltaTime;

        // Apply final movement

        Move(velocity * Time.deltaTime);

    }

    private bool CheckGrounded()

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2); // Inset the bounds to avoid self-collision

        // Cast a ray downwards from the bottom of the collider

        RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y + groundCheckDistance, groundLayer);

        // For debugging, draw the ray in the scene view

        Color rayColor = hit.collider != null ? Color.green : Color.red;

        Debug.DrawRay(bounds.center, Vector2.down * (bounds.extents.y + groundCheckDistance), rayColor);

        return hit.collider != null;

    }

    private void Move(Vector2 moveAmount)

    {

        // We need to check for collisions before moving

        // This is a simplified version; a full implementation would handle horizontal collisions too

        HandleCollisions(ref moveAmount);

        transform.Translate(moveAmount);

    }

    private void HandleCollisions(ref Vector2 moveAmount)

    {

        Bounds bounds = boxCollider.bounds;

        bounds.Expand(skinWidth * -2);

        // Vertical Collision Detection

        if (moveAmount.y < 0)

        {

            RaycastHit2D hit = Physics2D.Raycast(bounds.center, Vector2.down, bounds.extents.y - moveAmount.y, groundLayer);

            if (hit.collider != null)

            {

                // If we hit something below, adjust our movement amount

                moveAmount.y = -hit.distance + bounds.extents.y;

            }

        }

        // A full controller would also have horizontal and upward collision checks here

    }

}

Understanding the Code

Variables

  • moveSpeed & jumpHeight: Public variables you can tweak in the Inspector to change how the character feels.

  • groundLayer: This is crucial. We will assign our "Ground" layer here so our raycasts only detect collidable ground and not other objects like enemies or tri[9]ggers.

  • groundCheckDistance: A small extra distance for the ground check raycast to ensure it reliably detects the ground.

  • skinWidth: A very small offset to prevent the character from getting stuck inside other colliders.

  • gravity: A private variable to simulate gravity. We manually apply it since we aren't using Unity's physics engine.

  • velocity: A Vector2 that stores our character's current speed a[5][7][12]nd direction for this frame.

  • boxCollider: A reference to our player's BoxCollider2D component to get its size and position for raycasti[12]ng.

  • isGrounded: A boolean that tracks whether the player is currently on the ground.

Start() Method

  • We get and store a reference to the BoxCollider2D component. This is more efficient than calling GetComponent<>() every frame.

Update() Method

This is where the main logic happens every frame.

  1. Ground Check: We first call our CheckGrounded() function to determine if the player is standing on a valid surface.

  2. Reset Gravity: If we are grounded and still have some downward velocity from the previous frame, we reset velocity.y to 0. This prevents the character from accumulating downward speed while on the ground, which could cause it to fall through thin platforms.

  3. Horizontal Input: Input.GetAxisRaw("Horizontal") gets the player's input from the A/D keys or a controller's analog stick. It returns -1 for left, 1 for right, and 0 for no input. We multiply this by moveSpeed to get our horizontal velocity.

  4. Jumping: If the "Jump" button (space bar by default) is pressed and the player is grounded, we calculate the required upward velocity to reach our desired jumpHeight. The formula Mathf.Sqrt(-2f * gravity * jumpHeight) is derived from physics equations and gives us the perfect initial velocity for the jump.

  5. Apply Gravity: We constantly add the force of gravity to our vertical velocity. Time.deltaTime ensures that the gravity application is smooth and independent of the frame rate.

  6. Final Move: We call our custom Move() function, passing in the calculated velocity multiplied by Time.deltaTime to get the amount we should move this frame.

CheckGrounded() Method

This function is the core of our collision detection.

  • It gets the player collider's bounds, which is an axis-aligned bounding box surrounding the collider.

  • We then perform a Physics2D.Raycast. This is like shooting a tiny, invisible laser from a starting point, in a certain direction, with a maximum length.

  • bounds.center: The ray starts from the center of our collider.

  • Vector2.down: The ray shoots straight down.

  • bounds.extents.y + groundCheckDistance: The length of the ray is half the collider's height plus our small buffer distance.

  • groundLayer: The ray will only detect objects on this layer.

  • The function returns true if the ray hits a collider on the specified layer, and false otherwise.

Move() and HandleCollisions() Methods

The Move() method is simple: it calls HandleCollisions() and then applies the final, adjusted movement using transform.Translate().

The HandleCollisions() function is a simplified example of how you would prevent the character from passing through objects. It casts a ray in the direction of movement. If it detects a collision before the full movement is applied, it adjusts moveAmount to stop precisely at the surface of the object it hit.

Note: The provided HandleCollisions only checks downwards. A complete character controller would also need to cast rays horizontally (left and right) and upwards to handle wall and ceiling collisions.

Final Setup in the Unity Editor

  1. Go back to the Unity Editor and select your "Player" GameObject.

  2. In the Inspector, you will see the "Player Controller (Script)" component with its public variables.

  3. Assign the Ground Layer: Click the "None (Layer Mask)" dropdown next to the "Ground Layer" field and select your "Ground" layer.

  4. Adjust the Move Speed, Jump Height, and other settings to your liking. Move Speed of 5 and Jump Height of 4 are good starting points.

Hit Play!

You should now be able to move the character left and right with the A and D keys and jump with the space bar. The movement should feel precise and snappy. Because we are not using a Rigidbody2D, there is no inertia or sliding unless you program it yourself.

This controller provides a solid foundation. You can expand it by adding horizontal and ceiling collision checks, implementing coyote time and jump buffering for a better game feel, adding slope handling, and much more. By building the controller this way, you gain a deep understanding of how character physics work and have full authority over every aspect of your player's movement.


Comments

Popular posts from this blog

Step-by-Step Guide on How to Create a GDD (Game Design Document)

Unity Scriptable Objects: A Step-by-Step Tutorial

Unity 2D Tilemap Tutorial for Procedural Level Generation