One of the key elements of any 2D platformer is the main character. The way it moves and is controlled significantly shapes the game’s atmosphere — whether it's a cozy old-school game or a dynamic slasher. Therefore, creating a Character Controller is an important early stage in developing a platformer.
In this article, we will thoroughly examine the process of creating a character from scratch, teaching it to move around the level while adhering to the laws of physics. Even if you already have experience with creating Character Controllers, you will be interested to learn about the innovations in Unity 2023. To my surprise, a long-awaited Slide
method has been added for the Rigidbody2D
component, which greatly simplifies writing a character controller by allowing the use of Rigidbody2D
in Kinematic mode more effectively. Previously, all this functionality had to be implemented manually.
If you want not only to read the article but also to try it in practice, I recommend downloading a level template from the GitHub repository Treasure Hunters, where the necessary assets and a ready level for testing your character are already included.
For our platformer, we've set a rule that the game will only have vertical and horizontal surfaces, and the force of gravity will be directed strictly downward. This significantly simplifies the creation of the platformer at the initial stage, especially if you don't want to delve into vector mathematics.
In the future, I may deviate from these rules in my project Treasure Hunters, where I explore the creation of mechanics for a 2D platformer on Unity. But that will be the topic of another article.
Based on our decision that movement will be carried out along horizontal surfaces, the base of our character will be rectangular. Using inclined surfaces would require developing a capsule-shaped collider and additional mechanics such as sliding.
First, create an empty object on the scene and name it Captain — this will be our main character. Add Rigidbody2D
and BoxCollider2D
components to the object. Set the Rigidbody2D
type to Kinematic so that we can control the character's movement while still utilizing Unity's built-in physics capabilities. Also, lock the rotation of the character along the Z-axis by activating the Freeze Rotation Z option.
Your setup should look like the illustration below.
Now let's add an appearance to our captain. Find the texture in Assets/Textures/Treasure Hunters/Captain Clown Nose/Sprites/Captain Clown Nose/Captain Clown Nose with Sword/09-Idle Sword/Idle Sword 01.png and set its Pixel Per Unit value to 32. We will often use this value in this course because our textures are created with this resolution. Set the Sprite Mode to Single and, for convenience, set the Pivot to Bottom. Don't forget to apply the changes by clicking the Apply button. Make sure all settings are done correctly.
In this article, we won't touch on character animation, so for now, we'll just use one sprite. In the Captain object, create a nested object called Appearance and add a Sprite Renderer component to it, specifying the previously configured sprite.
When I zoomed in on the captain's image, I noticed it was quite blurry due to incorrect sprite settings. To fix this, select the Idle Sword 01 texture in the Project window and set the Filter Mode to Point (no filter). Now the image looks much better.
At the moment, the position of the collider and the image of our captain do not match, and the collider turns out to be too big for our needs.
Let's fix this and also correctly position the character's pivot at the bottom. We will apply this rule to all objects in the game to facilitate their placement on the same level regardless of size.
For ease of managing this process, use the Toggle Tool Handle Position with Pivot set, as shown below.
The next step is to adjust the collider of our hero so that its pivot point is exactly at the center bottom. The size of the collider should exactly match the dimensions of the character. Adjust the Offset and Size parameters of the collider, as well as the position of the nested Appearance object to achieve the necessary precision.
Pay special attention to the Offset.X parameter of the collider: its value must be strictly 0. This will ensure the collider's symmetrical placement relative to the center of the object, which is extremely important for subsequent character rotations left and right, where the value of Transform.Scale.X is changed to -1 and 1. The collider should remain in place, and the visual rotation should look natural.
First and foremost, it is essential to teach the characters—whether the main hero, NPCs, or enemies—to interact with the physical world. For example, characters should be able to walk on a flat surface, jump off it, and be subject to the force of gravity, pulling them back to the ground.
Unity has a built-in physics engine that controls the motion of bodies, handles collisions, and adds the effect of external forces on objects. This is all implemented using the Rigidbody2D
component. However, for characters, it is useful to have a more flexible tool that interacts with the physical world while giving developers more control over the object's behavior.
As I mentioned earlier, in previous versions of Unity, developers had to implement all this logic themselves. However, in Unity 2023, a new method Slide
was added to the Rigidbody2D
component, allowing flexible control of the physical object while providing all the necessary information about the movement performed.
Let's start by creating a CharacterBody
class, which will contain the basic logic for moving characters in the physical world. This class will use Rigidbody2D
in Kinematic
mode, which we have already added to our character. So, the first thing is to add a reference to this component.
public class CharacterBody : MonoBehaviour
{
[SerializeField] private Rigidbody2D _rigidbody;
}
Sometimes, for the dynamics of character movement, it is necessary for gravity to act more strongly than usual. To achieve this, we will add a gravity influence factor with an initial value of 1, and this factor cannot be less than 0.
[Min(0)]
[field: SerializeField] public float GravityFactor { get; private set; } = 1f;
We also need to define which objects will be considered impassable surfaces. To do this, we will create a field allowing us to specify the necessary layers.
[SerializeField] private LayerMask _solidLayers;
We will limit the character's speed to prevent them from developing excessively high speeds, for example, due to some external influence, including gravity. We set the initial value at 30 and limit the ability to set a value less than 0 in the inspector.
[Min(0)]
[SerializeField] private float _maxSpeed = 30;
When moving along a surface, we want the character to always cling to it if the distance between them is sufficiently small.
[Min(0)]
[SerializeField] private float _surfaceAnchor = 0.01f;
Although we decided that the surfaces in our game would only be horizontal or vertical, just in case, we will specify the maximum slope angle of the surface on which the character can stand stably, with an initial value of 45º.
[Range(0, 90)]
[SerializeField] private float _maxSlop = 45f;
Through the inspector, I also want to see the current speed of the character and their state, so I will add two fields with the attribute SerializeField
.
[SerializeField] private Vector2 _velocity;
[field: SerializeField] public CharacterState State { get; private set; }
Yes, here I introduced a new, yet undefined entity CharacterState
. We will discuss this further.
To simplify the development of our platformer, let's define just two main character states.
The first is Grounded, a state in which the character is securely standing on the surface. In this state, the character can freely move along the surface and jump off it.
The second is Airborne, a state of free fall, in which the character is in the air. The character's behavior in this state can vary depending on the specifics of the platformer. In a general case close to reality, the character moves under the influence of the initial momentum and cannot affect its behavior. However, in platformers, physics is often simplified in favor of convenience and gameplay dynamics: for example, in many games, even when in free fall, we can control the horizontal movement of the character. In our case, this is also possible, as well as the popular mechanic of the double jump, allowing an additional jump in the air.
Let's represent the states of our character in the code:
/// <summary>
/// Describes the state of <see cref="CharacterBody"/>.
/// </summary>
public enum CharacterState
{
/// <summary>
/// The character stays steady on the ground and can move freely along it.
/// </summary>
Grounded,
/// <summary>
/// The character is in a state of free fall.
/// </summary>
Airborne
}
It is worth noting that there can be many more states. For example, if the game includes sloped surfaces, the character may be in a sliding state where it cannot freely move right or left but can slide down the slope. In such a state, the character can also jump, pushing off from the slope, but only in the direction of the slope. Another possible case is sliding along a vertical wall, where the influence of gravity is weakened, and the character can push off horizontally.
We have already defined a private field _velocity
, but we need to be able to get and set this value from outside while limiting the character's maximum speed. This requires comparing the given velocity vector with the maximum allowable speed.
This can be done by calculating the length of the velocity vector, or in mathematical terms, its magnitude. The Vector2
structure already contains a magnitude
property that allows us to do this. Thus, if the magnitude of the passed vector exceeds the maximum allowed speed, we should maintain the direction of the vector but limit its magnitude. For this, we multiply _maxSpeed
by the normalized velocity vector (a normalized vector is a vector with the same direction but a magnitude of 1).
Here is what it looks like in the code:
public Vector2 Velocity
{
get => _velocity;
set => _velocity = value.magnitude > _maxSpeed
? value.normalized * _maxSpeed
: value;
}
Now let's take a close look at how the magnitude of a vector is calculated. It is defined by the formula:
Calculating the square root is a resource-intensive operation. Although in most cases the speed will not exceed the maximum, we still have to perform this comparison at least once per cycle. However, we can significantly simplify this operation if we compare the square of the vector's magnitude with the square of the maximum speed.
For this, we introduce an additional field to store the square of the maximum speed, and we calculate it once in the Awake
method:
private float _sqrMaxSpeed;
private void Awake()
{
_sqrMaxSpeed = _maxSpeed * _maxSpeed;
}
Now setting the speed can be performed more optimally:
public Vector2 Velocity
{
get => _velocity;
set => _velocity = value.sqrMagnitude > _sqrMaxSpeed
? value.normalized * _maxSpeed
: value;
}
Thus, we avoid unnecessary calculations and improve the performance of processing the character's movement speed.
As I mentioned earlier, Unity has added a new Slide()
method, which will greatly simplify the development of our CharacterBody
. However, before using this method, it is necessary to define the rules by which the object will move in space. This behavior is set by the Rigidbody2D.SlideMovement
structure.
Let's introduce a new field _slideMovement
and set its values.
private Rigidbody2D.SlideMovement _slideMovement;
private void Awake()
{
_sqrMaxSpeed = _maxSpeed * _maxSpeed;
_slideMovement = CreateSlideMovement();
}
private Rigidbody2D.SlideMovement CreateSlideMovement()
{
return new Rigidbody2D.SlideMovement
{
maxIterations = 3,
surfaceSlideAngle = 90,
gravitySlipAngle = 90,
surfaceUp = Vector2.up,
surfaceAnchor = Vector2.down * _surfaceAnchor,
gravity = Vector2.zero,
layerMask = _solidLayers,
useLayerMask = true,
};
}
It's important to explain that maxIterations
determines how many times an object can change direction as a result of a collision. For example, if the character is in the air next to a wall and the player tries to move it to the right while gravity acts on it. Thus, for each call to the Slide()
method, a velocity vector directed right and down will be set. Upon hitting the wall, the motion vector is recalculated, and the object will continue to move downward.
In such a situation, if the maxIterations
value was set to 1, the object would hit the wall, stop, and effectively get stuck there.
The values for maxIterations
and layerMask
were previously defined. For more detailed information on the other fields, see the official structure documentation.
Now everything is set to get Captain moving. We will do this in FixedUpdate
—a callback in Unity designed for handling physics. Over the past few years, the Unity team has significantly improved the handling of 2D physics. Currently, processing can be done in the Update
callback or even by calling the required method on your own.
However, in this example, we will use the traditional and proven FixedUpdate
method. Before proceeding, it is worth mentioning a few words about the value of Time.fixedDeltaTime
.
To ensure predictability in game physics, the simulation is carried out in iterations at fixed time intervals. This guarantees that changes in FPS or lags do not affect object behavior.
At the beginning of each cycle, we will account for the effect of gravity on the object. Since gravity is given by the vector of free fall acceleration, we can calculate the change in velocity Δv
of the object over time Δt
by the formula:
where a
is the constant acceleration of the object. In our case, it is the acceleration due to gravity, considering the coefficient we introduced — Physics2D.gravity * GravityFactor
. Therefore, Δv
can be calculated as follows:
Time.fixedDeltaTime * GravityFactor * Physics2D.gravity
The final result, where we change the velocity, looks like this:
Velocity += Time.fixedDeltaTime * GravityFactor * Physics2D.gravity;
Now we can perform the rigidbody movement of the character:
var slideResults = _rigidbody.Slide(
_velocity,
Time.fixedDeltaTime,
_slideMovement);
The variable slideResults
is a value of the SlideResults
structure and stores the results of the movement. The main fields of this result for us are slideHit
, the result of collision with the surface during movement, and surfaceHit
— the result of a downward cast, which will help determine if the character is standing on a stable surface.
When colliding with surfaces, it is crucial to limit the character's speed directed toward that surface. A simple example is if the character is standing still on the ground, they should not continue to gain speed under the influence of gravity. At the end of each cycle, their speed should be zero. Similarly, when moving upward and hitting the ceiling, the character should lose all vertical speed and start moving downward.
The results of collisions, slideHit
and surfaceHit
, are represented by values of the RaycastHit2D
structure, which includes the normal of the collision surface.
The speed limitation can be calculated by subtracting the projection of the original velocity vector on the collision normal from the velocity vector itself. This is done using the dot product. Let's write a method that will perform this operation:
private static Vector2 ClipVector(Vector2 vector, Vector2 hitNormal)
{
return vector - Vector2.Dot(vector, hitNormal) * hitNormal;
}
Now let's integrate this method into our FixedUpdate
. Here, for surfaceHit
, we will only limit the speed if it is directed downwards, as the cast determining whether the object is on the surface is always performed to check contact with the ground.
private void FixedUpdate()
{
Velocity += Time.fixedDeltaTime * GravityFactor * Physics2D.gravity;
var slideResults = _rigidbody.Slide(
_velocity,
Time.fixedDeltaTime,
_slideMovement);
if (slideResults.slideHit)
{
_velocity = ClipVector(_velocity, slideResults.slideHit.normal);
}
if (_velocity.y <= 0 && slideResults.surfaceHit)
{
var surfaceHit = slideResults.surfaceHit;
_velocity = ClipVector(_velocity, surfaceHit.normal);
}
}
This implementation allows for correctly managing the character's movement, avoiding unwanted acceleration when colliding with various surfaces, and keeping the movement of characters in the game predictable and smooth.
At the end of each cycle, it is necessary to determine whether the character is on a solid surface (Grounded state) or in free fall (or, as we defined it, controlled fall—Airborne state).
To consider the character as being in the Grounded state, primarily, their vertical speed must be zero or negative, which we determine by the value of _velocity.y
.
Another important criterion is the presence of a surface under the character's feet, which we identify from the Rigidbody movement results, namely through the presence of surfaceHit
.
The third factor is the angle of the surface's incline, which we analyze based on the normal of this surface, i.e., the value of surfaceHit.normal
. It is necessary to compare this angle with _maxSlop
— the maximum possible angle of the surface on which the character can stand stably.
For a completely vertical surface, the normal will be strictly horizontal, i.e., its vector value will be (1, 0) or (-1, 0). For a horizontal surface, the normal's value will be (0, 1). The smaller the angle of inclination, the greater the value of y
. For angle alpha
, this value can be calculated as:
Since our angle is given in degrees, and the function $\cos$ requires radians, the formula is transformed into:
For this, let's introduce a new field and calculate it in the Awake
method.
private float _minGroundVertical;
private void Awake()
{
_minGroundVertical = Mathf.Cos(_maxSlop * Mathf.PI / 180f);
//...
}
Now let's update our code in FixedUpdate
, checking all the above conditions.
if (_velocity.y <= 0 && slideResults.surfaceHit)
{
var surfaceHit = slideResults.surfaceHit;
Velocity = ClipVector(_velocity, surfaceHit.normal);
State = surfaceHit.normal.y >= _minGroundVertical
? CharacterState.Grounded
: CharacterState.Airborne;
}
else
{
State = CharacterState.Airborne;
}
This logic will allow us to accurately determine when the character is on the ground and respond correctly to changes in their state.
Now that our CharacterBody component is ready, the final step is to add it to our Captain. On the scene, select the Captain object and add the CharacterBody
component to it.
Don't forget to configure the Rigidbody as shown in the illustration above. Set the Gravity Factor to 3 and select the Default option for Solid Layer.
Now you can start the game and experiment with setting different values for Velocity to observe how our character moves around the scene.
Of course, we still need to add character controls. However, this article has already become quite lengthy, so I will detail the control of the character using the new Input System in the next article: "Creating a Character Controller 2D in Unity: Part 2."
You can download the complete project described in this article here: Treasure Hunters and check everything in practice if you encounter any difficulties. Developing a character controller is a key aspect in creating a 2D platformer, as it determines the further development of the game. It affects how easily new features will be added to the behavior of the main hero or enemies. Therefore, it is very important to understand the basics to be able to develop your own game independently.