Simple 2D Character Controller

>> using Unity engine 2018.2

25 minutes to complete

2D box character jumps between platforms

You will learn to build a 2D character controller for a platformer game using custom physics—no rigidbodies or forces.

At the heart of every great action or platforming game is a great character controller. Character controllers are responsible for controlling the physics of the character—how they move and interact with the world, and how the world interacts with them.

Unity does not come packaged with a 2D character controller. This article will demonstrate an implementation of a character controller for a 2D platformer. This controller will handle movement and jumping. To keep it simple, it will not handle sloped surfaces; however, the implementation is extensible enough to be adapted to any design.

Prerequisites

To complete this tutorial, you will need a working knowledge of Unity engine and C#.

Download starter project .zip

These tutorials are made possible, and kept free and open source, by your support. If you enjoy them, please consider becoming my patron through Patreon.

Become a Patron!

Getting started

Download the starter project provided above and open it in the Unity editor. We will build off this project to implement our character controller. Open the Main scene (located at the project root), and open the CharacterController2D script in your preferred code editor.

Inside CharacterController2D there are already a number of private fields exposed to the inspector. As well, there is a Vector2 to keep track of our controller's velocity, and a field that references a BoxCollider. Most 2D platformer games either use a box or a capsule shape; the shape of our controller will be a box.

1. Movement

Objects in Unity are moved primarily in two different ways: either by modifying the position of a transform directly or by applying a force to an object with a rigidbody and allowing Unity's physics system to decide how that object should move. We will be using the former technique to move our object around. This gives us very fine control over exactly how the controller will move.

Add the following line of code in the Update method. This will translate the controller by velocity every frame, multiplied by deltaTime to ensure our game is framerate independent

transform.Translate(velocity * Time.deltaTime);

Our velocity isn't being modified yet, so our controller won't move. Let's change that by adding some horizontal velocity when the left or right keys are pressed. Add the following at the top of the Update method.

float moveInput = Input.GetAxisRaw("Horizontal");
velocity.x = Mathf.MoveTowards(velocity.x, speed * moveInput, walkAcceleration * Time.deltaTime);

Mathf.MoveTowards is being used to move our current x velocity value to its target, our controller's speed (in the direction of our sampled input).

Note that when no keys are being pressed, moveInput will be zero, causing our controller to slow to a stop. This is fine, but we might want to have the deceleration rate different than our walkAcceleration. We can handle this by checking to see if moveInput has a non-zero value. Replace the line modifying velocity.x with the follow if statement.

if (moveInput != 0)
{
	velocity.x = Mathf.MoveTowards(velocity.x, speed * moveInput, walkAcceleration * Time.deltaTime);
}
else
{
	velocity.x = Mathf.MoveTowards(velocity.x, 0, groundDeceleration * Time.deltaTime);
}

2. Collision

The controller's side to side movement is working great, but it's currently able to pass through walls. This is happening due to our decision to use Transform.Translate to move around. This method tells Unity to move a transform, but does not tell it to handle collision detection or resolution.

2.1 Detection

We'll start with detection. Our goal is to find all colliders our controller is currently touching. We should do this after we have translated our controller to ensure no further movement will occur after we resolve collisions. Unity provides a series of Physics2D overlap functions to help detect intersections. We'll use OverlapBox. Insert the following code at the bottom of Update.

Collider2D[] hits = Physics2D.OverlapBoxAll(transform.position, boxCollider.size, 0);

This will give us an array of all colliders that are intersected by the box we defined, which is the same size as our BoxCollider and at the same position. Note that because of this, the array will also contain our own BoxCollider.

2.1 Resolution

At the end of collision resolution, we want our controller to be no longer intersecting any other colliders. To accomplish this, we will iterate over every collider we intersected, and push the controller out of each offending collider in turn.

The main problem is to decide which direction, and how far, we need to translate our controller to depenetrate from each collider. Ideally, we should move it the minimum distance required to be no longer touching the other collider. Unity provides a method to find that distance for us, Collider2D.Distance. Insert the following code at the end of Update.

foreach (Collider2D hit in hits)
{
	if (hit == boxCollider)
		continue;

	ColliderDistance2D colliderDistance = hit.Distance(boxCollider);

	if (colliderDistance.isOverlapped)
	{
		transform.Translate(colliderDistance.pointA - colliderDistance.pointB);
	}
}

As noted above, the array will contain our own BoxCollider—we skip it during our foreach loop.

Collider2D.Distance returns a custom object that contains some useful pieces of data. One of these is isOverlapped, which tells us if the two colliders are touching. Once we have ensured they are, we take the Vector2 from pointA to pointB. This is the shortest vector that will push our collider out of the other, resolving the collision.

3. Jumping

To complete our platformer controller, we will need to implement the ability to jump. Essential to this is the ability to detect when our character is grounded, or standing on the floor. This will allow us to know when our character is allowed to jump (i.e., prevent jumping when in the air).

3.1 Air movement

We'll begin by adding in the ability to jump by pressing the "Jump" button (this defaults to Spacebar in Unity). Insert the following at the top of Update.

if (Input.GetButtonDown("Jump"))
{
	velocity.y = Mathf.Sqrt(2 * jumpHeight * Mathf.Abs(Physics2D.gravity.y));
}

Once we're in the air, we'll need gravity to pull us back to the surface. Add this line below the one just inserted.

velocity.y += Physics2D.gravity.y * Time.deltaTime;

Note that in the skeleton project, Physics2D.gravity is set to (0, -25). This value can be modified in Edit > Project Settings > Physics 2D.

You'll notice two issues at this point: our controller can jump while in mid-air, and gravity is continuously applied even when on the ground. To resolve this, we'll need to know when our controller is grounded.

3.2 Detecting ground

Before being able to know when our character is on the ground, we have to first define what "ground" is in this context. We will define ground as any surface at with angle of less than 90 degrees with respect to the world up. Our controller will be defined as grounded if they have contacted a the ground.

We will accomplish this by testing the normal of each surface we collided with to see if it fulfills our criteria as "ground". Insert the following code at the specified locations.

// Insert as a new field in the class.
private bool grounded;

…

// Place above the foreach loop to clear the ground state each frame.
grounded = false;

…

// Place inside the foreach loop, just after the transform.Translate call.
if (Vector2.Angle(colliderDistance.normal, Vector2.up) < 90 && velocity.y < 0)
{
	grounded = true;
}

Two checks are performed in the above if statement. The first verifies that the angle between the collision normal and the world up is below 90. The second checks that our controller's vertical velocity is downwards—this way, we won't "ground" to nearby ledges when we are jumping alongside them.

Ground is now being correctly detected and stored in a field. We can use this data to solve the problems stated at the end of 3.1. Wrap the jumping code in the following if statement.

if (grounded)
{
	velocity.y = 0;

	// Jumping code we implemented earlier—no changes were made here.
	if (Input.GetButtonDown("Jump"))
	{
		// Calculate the velocity required to achieve the target jump height.
		velocity.y = Mathf.Sqrt(2 * jumpHeight * Mathf.Abs(Physics2D.gravity.y));
	}
}

Jumping is now only permitted when our controller is grounded. As well, we set our y velocity to zero each frame we are grounded. Our controller is very nearly complete, but we'll add a little bit more polish to the air controls before wrapping it up.

3.2 Air momentum

Most platforming games tend to restrict a player's control while they are in the air, typically by reducing how quickly they can accelerate. As well, there isn't usually any automatic deceleration applied when there is no movement input from the player. These design choices help add a feeling of weight and commitment to jumping, making it more exciting. Let's add them to our controller, inserting the code below immediately after gravity is applied, replacing the previous if statement.

float acceleration = grounded ? walkAcceleration : airAcceleration;
float deceleration = grounded ? groundDeceleration : 0;

// Update the velocity assignment statements to use our selected
// acceleration and deceleration values.
if (moveInput != 0)
{
	velocity.x = Mathf.MoveTowards(velocity.x, speed * moveInput, acceleration * Time.deltaTime);
}
else
{
	velocity.x = Mathf.MoveTowards(velocity.x, 0, deceleration * Time.deltaTime);
}

Here we select an acceleration and deceleration value based on whether we are grounded or not. This way, we can modify how "floaty" our controller feels while jumping.

Conclusion

The controller built in this lesson can be used as a solid foundation for nearly any kind of 2D project. Although the mechanics and interactions will vary from game to game, the core fundementals—velocity, collision detection and resolution, grounding—tend to always be present.

View source GitHub repository

Leave me a message

Send me some feedback about the tutorial in the form below. I'll get back to you as soon as I can! You can alternatively message me through Twitter or Reddit.