paint-brush
How to Create a 2D Character Controller in Unity: Part 2by@deniskondratev
444 reads
444 reads

How to Create a 2D Character Controller in Unity: Part 2

by Denis KondratevDecember 8th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

This article shows how to enhance a Unity 2D character controller, covering new input system setup, improved jumping mechanics, and seamless camera following.
featured image - How to Create a 2D Character Controller in Unity: Part 2
Denis Kondratev HackerNoon profile picture

In this article, we continue developing a character controller for a 2D platformer in Unity, thoroughly examining each step of configuring and optimizing controls.


In the previous article, “How to Create a 2D Character Controller in Unity: Part 1”, we discussed in detail how to create the character's foundation, including its physical behavior and basic movement. Now, it’s time to move on to more advanced aspects, such as input handling and dynamic camera following.


In this article, we’ll delve into setting up Unity’s new input system, creating active actions to control the character, enabling jumping, and ensuring proper responses to player commands.

If you want to implement all the changes described in this article yourself, you can download the “Character Body” repository branch, which contains the foundation for this article. Alternatively, you can download the “Character Controller” branch with the final result.

Setting Up the Input System

Before we start writing code to control our character, we need to configure the input system in the project. For our platformer, we’ve chosen Unity’s new Input System, introduced a few years ago, which remains relevant due to its advantages over the traditional system.


The Input System offers a more modular and flexible approach to input handling, allowing developers to easily set up controls for various devices and support more complex input scenarios without additional implementation overhead.


First, install the Input System package. Open the Package Manager from the main menu by selecting Window → Package Manager. In the Unity Registry section, find the "Input System" package and click "Install".

Next, go to the project settings via the Edit → Project Settings menu. Select the Player tab, find the Active Input Handling section, and set it to "Input System Package (New).

After completing these steps, Unity will prompt you to restart. Once restarted, everything will be ready to configure controls for our captain.

Creating Input Actions

In the Settings folder, create Input Actions via the main menu: Assets → Create → Input Actions. Name the file "Controls."

Unity’s Input System is a powerful and flexible input management tool that allows developers to configure controls for characters and game elements. It supports various input devices. The Input Actions you create provide centralized input management, simplifying setup and making the interface more intuitive.


Double-click the Controls file to open it for editing, and add an Action Map for character control named "Character."

An Action Map in Unity is a collection of actions that can be linked to various controllers and keys to perform specific tasks in the game. It’s an efficient way to organize controls, enabling developers to allocate and adjust inputs without rewriting code. For more details, refer to the official Input System documentation.


The first action will be called "Move." This action will define the character’s movement direction. Set the Action Type to "Value" and the Control Type to "Vector2" to enable movement in four directions.

Assign bindings to this action by selecting Add Up/Down/Right/Left Composite and assigning the familiar WASD keys to their respective directions.

Don’t forget to save your settings by clicking Save Asset. This setup ensures that you can reassign bindings for the "Move" action, for example, to arrow keys or even a gamepad joystick.


Next, add a new action — "Jump." Keep the Action Type as "Button", but add a new Interaction — "Press", and set Trigger Behavior to "Press And Release", since we need to capture both the button press and release.

This completes the character control scheme. The next step is to write a component to handle these actions.

Moving the Character Left and Right

It's time to link the Input Actions we created for character control to the CharacterBody component, enabling the character to actively move across the scene according to our control commands.


To do this, we’ll create a script responsible for movement control and name it CharacterController for clarity. In this script, we’ll first define some basic fields. We’ll add a reference to the CharacterBody component, _characterBody, which will be directly controlled by the script.


We’ll also set parameters for the character's movement speed (_speed) and jump height (_jumpHeight). Additionally, we’ll define the purpose of the _stopJumpFactor field.


You may have noticed that in many 2D platformers, jump height can be controlled. The longer the jump button is held, the higher the character jumps. Essentially, an initial upward velocity is applied at the start of the jump, and this velocity is reduced when the button is released. The _stopJumpFactor determines by how much the upward velocity decreases upon releasing the jump button.


Here’s an example of the code we’ll write:


// CharacterController.cs

public class CharacterController : MonoBehaviour
{
    [SerializeField] private CharacterBody _characterBody;

    [Min(0)]
    [SerializeField] private float _speed = 5;

    [Min(0)]
    [SerializeField] private float _jumpHeight = 2.5f;

    [Min(1)]
    [SerializeField] private float _stopJumpFactor = 2.5f;
}


Next, we’ll implement the ability to move the character left and right. When holding down the movement button, the character should maintain the specified movement speed regardless of obstacles. To achieve this, we’ll add a variable in the script to store the current movement velocity along the surface (or simply horizontally when the character is airborne):


// CharacterController.cs

private float _locomotionVelocity;


In the CharacterBody component, we’ll introduce a method to set this velocity:


// CharacterBody.cs

public void SetLocomotionVelocity(float locomotionVelocity)
{
    Velocity = new Vector2(locomotionVelocity, _velocity.y);
}


Since our game does not feature sloped surfaces, this method is quite simple. In more complex scenarios, we would need to account for the body’s state and the surface slope. For now, we simply preserve the vertical component of the velocity while modifying only the horizontal x coordinate.


Next, we’ll set this value in the Update method on every frame:


// CharacterController.cs

private void Update()
{
    _characterBody.SetLocomotionVelocity(_locomotionVelocity);
}


We’ll define a method to handle signals from the Move Input Action:


// CharacterController.cs

public void OnMove(InputAction.CallbackContext context)
{
    var value = context.ReadValue<Vector2>();
    _locomotionVelocity = value.x * _speed;
}


Since the Move action is defined as a Vector2, the context will provide a vector value depending on which keys are pressed or released. For example, pressing the D key will result in the method OnMove receiving the vector (1, 0). Pressing both D and W simultaneously will result in (1, 1). Releasing all keys will trigger OnMove with the value (0, 0).


For the A key, the vector will be (-1, 0). In the OnMove method, we take the horizontal component of the received vector and multiply it by the specified movement speed, _speed.

Teaching the Character to Jump

First, we need to teach the CharacterBody component to handle jumping. To do this, we’ll add a method responsible for the jump:


// CharacterBody.cs

public void Jump(float jumpSpeed)
{
    Velocity = new Vector2(_velocity.x, jumpSpeed);
    State = CharacterState.Airborne;
}


In our case, this method is straightforward: it sets the vertical velocity and immediately changes the character's state to Airborne.


Next, we need to determine the speed at which the character should jump. We’ve already defined the jump height and know that gravity constantly acts on the body. Based on this, the initial jump speed can be calculated using the formula:



Where h is the jump height, and g is the gravitational acceleration. We’ll also account for the gravity multiplier present in the CharacterBody component. We’ll add a new field to define the initial jump speed and calculate it as follows:


// CharacterController.cs

private float _jumpSpeed;

private void Awake()
{
    _jumpSpeed = Mathf.Sqrt(2 * Physics2D.gravity.magnitude
                            * _characterBody.GravityFactor
                            * _jumpHeight);
}


We’ll need another field to track whether the character is currently jumping, so we can limit the jump speed at the appropriate time.


Additionally, if the player holds the jump button until landing, we should reset this flag ourselves. This will be done in the Update method:


// CharacterController.cs

private bool _isJumping;

private void Update()
{
    if (_characterBody.State == CharacterState.Grounded)
    {
        _isJumping = false;
    }

    //...
}


Now, let’s write the method to handle the Jump action:


// CharacterController.cs

public void OnJump(InputAction.CallbackContext context)
{
    if (context.started)
    {
        Jump();
    }
    else if (context.canceled)
    {
        StopJumping();
    }
}


Since the Jump action is a button, we can determine from the context whether the button press has started (context.started) or ended (context.canceled). Based on this, we either initiate or stop the jump.


Here’s the method to execute the jump:


// CharacterController.cs

private void Jump()
{
    if (_characterBody.State == CharacterState.Grounded)
    {
        _isJumping = true;
        _characterBody.Jump(_jumpSpeed);
    }
}


Before jumping, we check if the character is on the ground. If so, we set the _isJumping flag and make the body jump with the _jumpSpeed.

Now, let’s implement the stop-jumping behavior:


// CharacterController.cs

private void StopJumping()
{
    var velocity = _characterBody.Velocity;

    if (_isJumping && velocity.y > 0)
    {
        _isJumping = false;
        _characterBody.Velocity = new Vector2(
            velocity.x,
            velocity.y / _stopJumpFactor);
    }
}


We stop the jump only if the _isJumping flag is active. Another important condition is that the character must be moving upward. This prevents limiting the fall speed if the jump button is released while moving downward. If all conditions are met, we reset the _isJumping flag and reduce the vertical velocity by a factor of _stopJumpFactor.

Setting Up the Character

Now that all components are ready, add the PlayerInput and CharacterController components to the Captain object in the scene. Make sure to select the CharacterController component that we created, not the standard Unity component designed for controlling 3D characters.


For the CharacterController, assign the existing CharacterBody component from the character. For the PlayerInput, set the previously created Controls in the Actions field.

Next, configure the PlayerInput component to call the appropriate methods from CharacterController. Expand the Events and Character sections in the editor, and link the corresponding methods to the Move and Jump actions.

Now, everything is ready to run the game and test how all the configured components work together.


Camera Movement

Now, we need to make the camera follow the character wherever they go. Unity provides a powerful tool for camera management — Cinemachine.


Cinemachine is a revolutionary solution for camera control in Unity that offers developers a wide range of capabilities for creating dynamic, well-tuned camera systems that adapt to gameplay needs. This tool makes it easy to implement complex camera techniques, such as character following, automatic focus adjustment, and much more, adding vitality and richness to every scene.


First, locate the Main Camera object in the scene, and add the CinemachineBrain component to it.

Next, create a new object in the scene named CaptainCamera. This will be the camera that follows the captain, just like a professional cameraman. Add the CinemachineVirtualCamera component to it. Set the Follow field to the captain, choose Framing Transposer for the Body field, and set the Lens Ortho Size parameter to 4.


Additionally, we’ll need another component to define the camera’s offset relative to the character — CinemachineCameraOffset. Set the Y value to 1.5 and the Z value to -15.

Now, let’s test how the camera follows our character.



I think it turned out quite well. I noticed that the camera occasionally stutters slightly. To fix this, I set the Blend Update Method field of the Main Camera object to FixedUpdate.

Improving Jumps

Let’s test the updated mechanics. Try running and continuously jumping. Experienced gamers may notice that the jumps don’t always register. In most games, this isn’t an issue.


It turns out it’s hard to predict the exact landing time to press the jump button again. We need to make the game more forgiving by allowing players to press jump slightly before landing and have the character jump immediately upon landing. This behavior aligns with what gamers are accustomed to.


To implement this, we’ll introduce a new variable, _jumpActionTime, which represents the window of time during which a jump can still be triggered if the opportunity arises.


// CharacterController.cs

[Min(0)]
[SerializeField] private float _jumpActionTime = 0.1f;


I added a _jumpActionEndTime field, which marks the end of the jump action window. In other words, until _jumpActionEndTime is reached, the character will jump if the opportunity arises. Let’s also update the Jump action handler.


// CharacterController.cs

private float _jumpActionEndTime;

public void OnJump(InputAction.CallbackContext context)
{
    if (context.started)
    {
        if (_characterBody.State == CharacterState.Grounded)
        {
            Jump();
        }
        else
        {
            _jumpActionEndTime = Time.unscaledTime + _jumpActionTime;
        }
    }
    else if (context.canceled)
    {
        StopJumping();
    }
}


When the jump button is pressed, if the character is on the ground, they jump immediately. Otherwise, we store the time window during which the jump can still be performed.


Let’s remove the Grounded state check from the Jump method itself.


// CharacterController.cs

private void Jump()
{
    _isJumping = true;
    _characterBody.Jump(_jumpSpeed);
}


We’ll also adapt the stop-jumping method. If the button was released before landing, no jump should occur, so we reset _jumpActionEndTime.


// CharacterController.cs

private void StopJumping()
{
    _jumpActionEndTime = 0;
    //...
}


When should we check that the character has landed and trigger a jump? The CharacterBody state is processed in FixedUpdate, while action processing occurs later. Regardless of whether it’s Update or FixedUpdate, a one-frame delay between landing and jumping can occur, which is noticeable.


We’ll add a StateChanged event to CharacterBody to respond instantly to landing. The first argument will be the previous state, and the second will be the current state.


// CharacterBody.cs

public event Action<CharacterState, CharacterState> StateChanged;


We’ll adjust the state management to trigger the state change event and rewrite FixedUpdate.


// CharacterBody.cs

[field: SerializeField] private CharacterState _state;

public CharacterState State
{
    get => _state;

    private set
    {
        if (_state != value)
        {
            var previousState = _state;
            _state = value;
            StateChanged?.Invoke(previousState, value);
        }
    }
}


I also refined how surfaceHit is handled in FixedUpdate.


// CharacterBody.cs

private void FixedUpdate()
{
    //...

    if (_velocity.y <= 0 && slideResults.surfaceHit)
    {
        var surfaceHit = slideResults.surfaceHit;
        Velocity = ClipVector(_velocity, surfaceHit.normal);

        if (surfaceHit.normal.y >= _minGroundVertical)
        {
            State = CharacterState.Grounded;
            return;
        }
    }

    State = CharacterState.Airborne;
}


In CharacterController, we’ll subscribe to the StateChanged event and add a handler.


// CharacterController.cs

private void OnEnable()
{
    _characterBody.StateChanged += OnStateChanged;
}

private void OnDisable()
{
    _characterBody.StateChanged -= OnStateChanged;
}

private void OnStateChanged(CharacterState previousState, CharacterState state)
{
    if (state == CharacterState.Grounded)
    {
        OnGrounded();
    }
}


We’ll remove the Grounded state check from Update and move it to OnGrounded.


// CharacterController.cs

private void Update()
{
    _characterBody.SetLocomotionVelocity(_locomotionVelocity);
}

private void OnGrounded()
{
    _isJumping = false;
}


Now, add the code to check whether a jump should be triggered.


// CharacterController.cs

private void OnGrounded()
{
    _isJumping = false;

    if (_jumpActionEndTime > Time.unscaledTime)
    {
        _jumpActionEndTime = 0;
        Jump();
    }
}


If _jumpActionEndTime is greater than the current time, it means the jump button was pressed recently, so we reset _jumpActionEndTime and perform the jump.


Now, try continuously jumping with the character. You’ll notice the jump button feels more responsive, and controlling the character becomes smoother. However, I observed that in certain situations, such as the corner shown in the illustration below, the Grounded state experiences a slight delay, interrupting the jump chain.

To address this, I set the Surface Anchor field in the CharacterBody component to 0.05 instead of 0.01. This value represents the minimum distance to a surface for the body to enter the Grounded state.

Cliff Jumping

You may have noticed that attempting to jump while running off vertical surfaces doesn’t always work. It can feel like the jump button sometimes doesn’t respond.


This is one of the subtleties of developing a Character Controller for 2D platformers. Players need the ability to jump even when they are slightly late pressing the jump button. While this concept may seem odd, it is how most platformers function. The result is a character appearing to push off the air, as demonstrated in the animation below.



Let’s implement this mechanic. We’ll introduce a new field to store the time window (in seconds) during which the character can still jump after losing the Grounded state.


// CharacterController.cs

[Min(0)]
[SerializeField] private float _rememberGroundTime = 0.1f;


We’ll also add another field to store the timestamp after which the Grounded state is "forgotten."


// CharacterController.cs

private float _lostGroundTime;


This state will be tracked using the CharacterBody event. We’ll adjust the OnStateChanged handler for this purpose.


// CharacterController.cs

private void OnStateChanged(CharacterState previousState, CharacterState state)
{
    if (state == CharacterState.Grounded)
    {
        OnGrounded();
    }
    else if (previousState == CharacterState.Grounded)
    {
        _lostGroundTime = Time.unscaledTime + _rememberGroundTime;
    }
}


It’s important to distinguish whether the character lost the Grounded state due to an intentional jump or for another reason. We already have the _isJumping flag, which is disabled each time StopJumping is called to prevent redundant actions.


I decided not to introduce another flag since redundant jump cancellation doesn’t affect gameplay. Feel free to experiment. The _isJumping flag will now only be cleared when the character lands after jumping. Let’s update the code accordingly.


// CharacterController.cs

private void StopJumping()
{
    _jumpActionEndTime = 0;
    var velocity = _characterBody.Velocity;

    if (_isJumping && velocity.y > 0)
    {
        _characterBody.Velocity = new Vector2(
            velocity.x,
            velocity.y / _stopJumpFactor);
    }
}


Finally, we’ll revise the OnJump method.


// CharacterController.cs

public void OnJump(InputAction.CallbackContext context)
{
    if (context.started)
    {
        if (_characterBody.State == CharacterState.Grounded
            || (!_isJumping && _lostGroundTime > Time.unscaledTime))
        {
            Jump();
        }
        else
        {
            _jumpActionEndTime = Time.unscaledTime + _jumpActionTime;
        }
    }
    else if (context.canceled)
    {
        StopJumping();
    }
}


Now, jumping off vertical surfaces no longer disrupts gameplay rhythm and feels much more natural, despite its apparent absurdity. The character can literally push off the air, going farther than seems logical. But this is exactly what’s needed for our platformer.

Character Flip

The final touch is making the character face the direction of movement. We’ll implement this in the simplest way — by changing the character’s scale along the x-axis. Setting a negative value will make our captain face the opposite direction.

First, let’s store the original scale in case it differs from 1.


// CharacterController.cs

public class CharacterController : MonoBehaviour
{
    //...

    private Vector3 _originalScale;

    private void Awake()
    {
        //...

        _originalScale = transform.localScale;
    }
}


Now, when moving left or right, we’ll apply a positive or negative scale.


// CharacterController.cs

public class CharacterController : MonoBehaviour
{
    public void OnMove(InputAction.CallbackContext context)
    {
        //...

        // Change character's direction.
        if (value.x != 0)
        {
            var scale = _originalScale;
            scale.x = value.x > 0 ? _originalScale.x : -_originalScale.x;
            transform.localScale = scale;
        }
    }
}


Let’s test the result.


Wrapping Up

This article turned out to be quite detailed, but we managed to cover all the essential aspects of character control in a 2D platformer. As a reminder, you can check out the final result in the “Character Controller” branch of the repository.


If you enjoyed or found this and the previous article helpful, I’d appreciate likes and stars on GitHub. Don’t hesitate to reach out if you encounter any issues or find mistakes. Thank you for your attention!