Chat
Ask me anything
Ithy Logo

Creating a Comprehensive Player Movement Script for a 2D Side-Scroller in Unity

Build engaging movement mechanics for your game with this step-by-step guide.

unity 2d platformer gameplay

Key Takeaways

  • Robust Movement Mechanics: Implement smooth horizontal movement and responsive jumping to enhance gameplay.
  • Ground Detection: Accurately detect when the player is grounded to prevent unintended mid-air jumps.
  • Expandable and Customizable: Easily extend the script with additional features like dashing, double jumping, or animations.

Introduction

Developing a 2D side-scrolling game in Unity requires a well-functioning player movement system to provide a smooth and enjoyable gaming experience. This comprehensive guide will walk you through creating a robust player movement script using C#, covering horizontal movement, jumping mechanics, and ground detection. By the end of this tutorial, you'll have a solid foundation upon which you can build and expand your game's movement features.

Setting Up Your Unity Project

1. Create a New 2D Unity Project

Start by launching Unity and creating a new project:

  1. Open Unity Hub.
  2. Click on the New Project button.
  3. Select the 2D template.
  4. Name your project (e.g., "2DSideScroller") and choose a save location.
  5. Click Create.

2. Add a Player Sprite

To visualize the player, add a sprite to your scene:

  1. In the Hierarchy window, right-click and select 2D Object > Sprite.
  2. Name the new GameObject Player.
  3. In the Inspector window, assign a sprite to the Sprite Renderer component (you can use the default square or import a custom sprite).

3. Configure Physics Components

To enable physics-based movement, add necessary components to the player:

  1. Select the Player GameObject.
  2. Click on Add Component in the Inspector and add a Rigidbody2D component.
  3. Ensure the Body Type is set to Dynamic.
  4. Add a BoxCollider2D or CircleCollider2D for collision detection.

Creating the Player Movement Script

1. Create the C# Script

Now, create the script that will handle player movement:

  1. In the Project window, navigate to the Assets folder.
  2. Right-click and select Create > C# Script.
  3. Name the script PlayerMovement.
  4. Double-click the script to open it in your code editor.

2. Writing the Movement Code

Replace the default code with the following comprehensive script:

// PlayerMovement.cs
using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    public float moveSpeed = 5f; // Horizontal movement speed
    public float jumpForce = 12f; // Upward jump force

    [Header("Ground Detection Settings")]
    public Transform groundCheck; // Empty GameObject to check ground
    public float groundCheckRadius = 0.2f; // Radius for ground detection
    public LayerMask groundLayer; // Layer for ground objects

    private Rigidbody2D rb; // Rigidbody2D component
    private bool isGrounded; // Whether the player is on the ground
    private float moveInput; // Horizontal input

    private void Start()
    {
        // Initialize Rigidbody2D component
        rb = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        // Get horizontal input
        moveInput = Input.GetAxisRaw("Horizontal");

        // Check for jump input
        if (Input.GetButtonDown("Jump") && isGrounded)
        {
            Jump();
        }
    }

    private void FixedUpdate()
    {
        // Handle horizontal movement
        rb.velocity = new Vector2(moveInput * moveSpeed, rb.velocity.y);

        // Check if the player is grounded
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
    }

    private void Jump()
    {
        // Apply jump force
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
    }

    // Optional: Visualize ground check in the editor
    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

The script includes:

  • Public Variables: Easily adjustable parameters for movement speed, jump force, ground detection radius, and ground layers.
  • Ground Detection: Uses Physics2D.OverlapCircle to detect if the player is on the ground.
  • Responsive Controls: Handles horizontal movement and jumping in both Update and FixedUpdate methods for smooth physics interactions.
  • Visual Debugging: OnDrawGizmosSelected helps visualize the ground check area in the Unity Editor.

3. Attaching the Script to the Player

  1. Select the Player GameObject in the Hierarchy.
  2. Drag and drop the PlayerMovement script onto the Inspector panel.

4. Setting Up Ground Detection

Ground detection ensures that the player can only jump when touching the ground:

  1. Create an empty child GameObject under the Player:
    1. Right-click on the Player in the Hierarchy and select Create Empty.
    2. Name it GroundCheck.
    3. Position it at the bottom of the player sprite (e.g., Y = -0.5).
  2. Assign this GroundCheck to the groundCheck field in the PlayerMovement script.
  3. Create a new layer for ground objects:
    1. Go to the top of the Unity window, click on Layer, then Add Layer.
    2. Name one of the layers Ground.
  4. Assign the Ground layer to all ground-related GameObjects (e.g., platforms, terrain).
  5. In the PlayerMovement script, set the Ground Layer to the Ground layer.

5. Configuring the Rigidbody2D

Ensure the Rigidbody2D is set up correctly:

  1. Select the Player GameObject.
  2. In the Inspector, find the Rigidbody2D component.
  3. Set the Gravity Scale to a value that feels natural (e.g., 3).
  4. Ensure Freeze Rotation Z is enabled to prevent the player from rotating.

Enhancing the Movement Script

1. Introducing Acceleration and Deceleration

For more realistic movement, implement acceleration and deceleration:

// Enhanced PlayerMovement.cs
using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    public float moveSpeed = 5f;
    public float acceleration = 10f;
    public float deceleration = 10f;
    public float jumpForce = 12f;

    [Header("Ground Detection Settings")]
    public Transform groundCheck;
    public float groundCheckRadius = 0.2f;
    public LayerMask groundLayer;

    private Rigidbody2D rb;
    private bool isGrounded;
    private float moveInput;

    private void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump") && isGrounded)
        {
            Jump();
        }
    }

    private void FixedUpdate()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Limit the maximum speed
        if (Mathf.Abs(rb.velocity.x) > moveSpeed)
        {
            rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

This enhancement introduces:

  • Acceleration and Deceleration: Smoothly accelerates the player to maximum speed and decelerates when stopping.
  • Force-Based Movement: Applies forces instead of directly setting velocity for more natural movement.
  • Speed Limiting: Ensures the player doesn't exceed the defined moveSpeed.

2. Implementing Jump Buffer and Coyote Time

To make jumping more responsive and forgiving, add a jump buffer and coyote time:

// Enhanced PlayerMovement with Jump Buffer and Coyote Time
using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    public float moveSpeed = 5f;
    public float acceleration = 10f;
    public float deceleration = 10f;
    public float jumpForce = 12f;

    [Header("Ground Detection Settings")]
    public Transform groundCheck;
    public float groundCheckRadius = 0.2f;
    public LayerMask groundLayer;

    [Header("Jump Settings")]
    public float jumpBufferTime = 0.2f;
    public float coyoteTime = 0.2f;

    private Rigidbody2D rb;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }
    }

    private void FixedUpdate()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }

        // Jump logic with buffer and coyote time
        if (jumpBufferCounter > 0f && coyoteCounter > 0f)
        {
            Jump();
            jumpBufferCounter = 0f;
            coyoteCounter = 0f;
        }

        // Movement
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Limit the maximum speed
        if (Mathf.Abs(rb.velocity.x) > moveSpeed)
        {
            rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

This addition provides:

  • Jump Buffer: Allows the player to press the jump button shortly before landing and still register the jump.
  • Coyote Time: Permits the player to jump within a short duration after leaving the ground.

3. Adding Character Flipping

To make the player face the direction of movement, implement character flipping:

// Enhanced PlayerMovement with Character Flipping
using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    public float moveSpeed = 5f;
    public float acceleration = 10f;
    public float deceleration = 10f;
    public float jumpForce = 12f;

    [Header("Ground Detection Settings")]
    public Transform groundCheck;
    public float groundCheckRadius = 0.2f;
    public LayerMask groundLayer;

    [Header("Jump Settings")]
    public float jumpBufferTime = 0.2f;
    public float coyoteTime = 0.2f;

    private Rigidbody2D rb;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private bool isFacingRight = true;

    private void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }

        // Handle character flipping
        if (moveInput > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (moveInput < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void FixedUpdate()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }

        // Jump logic with buffer and coyote time
        if (jumpBufferCounter > 0f && coyoteCounter > 0f)
        {
            Jump();
            jumpBufferCounter = 0f;
            coyoteCounter = 0f;
        }

        // Movement
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Limit the maximum speed
        if (Mathf.Abs(rb.velocity.x) > moveSpeed)
        {
            rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        Vector3 scaler = transform.localScale;
        scaler.x *= -1;
        transform.localScale = scaler;
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

Features added:

  • Character Flipping: Automatically flips the player sprite based on movement direction.
  • Direction Tracking: Keeps track of the player’s facing direction with the isFacingRight boolean.

4. Implementing Animation Integration

Integrate animations to enhance visual feedback:

  1. Setup Animator Controller:
    1. Create an Animator Controller by right-clicking in the Project window and selecting Create > Animator Controller.
    2. Name it PlayerAnimator.
    3. Assign it to the Animator component on the Player.
  2. Create Animation States:
    1. Create animations such as Idle, Run, and Jump.
    2. Set up transitions between these states based on parameters.
  3. Modify the Script for Animation:
    1. Add a reference to the Animator component.
    2. Update animation parameters based on movement.
// Enhanced PlayerMovement with Animation
using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    public float moveSpeed = 5f;
    public float acceleration = 10f;
    public float deceleration = 10f;
    public float jumpForce = 12f;

    [Header("Ground Detection Settings")]
    public Transform groundCheck;
    public float groundCheckRadius = 0.2f;
    public LayerMask groundLayer;

    [Header("Jump Settings")]
    public float jumpBufferTime = 0.2f;
    public float coyoteTime = 0.2f;

    private Rigidbody2D rb;
    private Animator anim;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private bool isFacingRight = true;

    private void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
    }

    private void Update()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }

        // Handle character flipping
        if (moveInput > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (moveInput < 0 && isFacingRight)
        {
            Flip();
        }

        // Update Animator parameters
        anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
        anim.SetBool("isGrounded", isGrounded);
    }

    private void FixedUpdate()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }

        // Jump logic with buffer and coyote time
        if (jumpBufferCounter > 0f && coyoteCounter > 0f)
        {
            Jump();
            jumpBufferCounter = 0f;
            coyoteCounter = 0f;
        }

        // Movement
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Limit the maximum speed
        if (Mathf.Abs(rb.velocity.x) > moveSpeed)
        {
            rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
        anim.SetTrigger("Jump");
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        Vector3 scaler = transform.localScale;
        scaler.x *= -1;
        transform.localScale = scaler;
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

Additional features:

  • Animator Reference: Accesses the Animator component to control animations.
  • Animation Parameters: Sets Speed and isGrounded parameters to switch between animations.
  • Triggering Jump Animation: Uses SetTrigger("Jump") to initiate the jump animation.

5. Configuring the Animator

Ensure your Animator Controller is set up with the necessary parameters and transitions:

  1. Open the PlayerAnimator in the Animator window.
  2. Create Idle, Run, and Jump animation states.
  3. Add Speed (float) and isGrounded (bool) as parameters in the Animator.
  4. Set up transitions:
    1. From Idle to Run when Speed > 0.1.
    2. From Run to Idle when Speed < 0.1.
    3. From any state to Jump when Jump trigger is set.
    4. From Jump back to Idle or Run based on isGrounded.

Optimizing the Player Movement Script

1. Organizing Code for Readability

Refactor the code to enhance readability and maintainability:

// Optimized PlayerMovement.cs
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float acceleration = 10f;
    [SerializeField] private float deceleration = 10f;
    [SerializeField] private float jumpForce = 12f;

    [Header("Ground Detection Settings")]
    [SerializeField] private Transform groundCheck;
    [SerializeField] private float groundCheckRadius = 0.2f;
    [SerializeField] private LayerMask groundLayer;

    [Header("Jump Settings")]
    [SerializeField] private float jumpBufferTime = 0.2f;
    [SerializeField] private float coyoteTime = 0.2f;

    private Rigidbody2D rb;
    private Animator anim;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private bool isFacingRight = true;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
    }

    private void Update()
    {
        HandleInput();
        UpdateAnimations();
    }

    private void FixedUpdate()
    {
        CheckGroundStatus();
        HandleMovement();
        HandleJump();
    }

    private void HandleInput()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }

        HandleCharacterFlip();
    }

    private void HandleCharacterFlip()
    {
        if (moveInput > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (moveInput < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void CheckGroundStatus()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }
    }

    private void HandleMovement()
    {
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Clamp the velocity
        rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
    }

    private void HandleJump()
    {
        if (jumpBufferCounter > 0f && coyoteCounter > 0f)
        {
            Jump();
            jumpBufferCounter = 0f;
            coyoteCounter = 0f;
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
        anim.SetTrigger("Jump");
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        Vector3 scaler = transform.localScale;
        scaler.x *= -1;
        transform.localScale = scaler;
    }

    private void UpdateAnimations()
    {
        anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
        anim.SetBool("isGrounded", isGrounded);
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

Improvements made:

  • Require Components: Ensures Rigidbody2D and Animator are present.
  • Serialized Fields: Makes variables private yet editable in the Inspector.
  • Method Organization: Breaks down functionality into smaller, manageable methods.
  • Clamped Velocity: Ensures the player's velocity does not exceed the set speed in either direction.

2. Adding Dashing Mechanic

Introduce a dash ability to add depth to player movement:

// PlayerMovement with Dashing
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float acceleration = 10f;
    [SerializeField] private float deceleration = 10f;
    [SerializeField] private float jumpForce = 12f;

    [Header("Ground Detection Settings")]
    [SerializeField] private Transform groundCheck;
    [SerializeField] private float groundCheckRadius = 0.2f;
    [SerializeField] private LayerMask groundLayer;

    [Header("Jump Settings")]
    [SerializeField] private float jumpBufferTime = 0.2f;
    [SerializeField] private float coyoteTime = 0.2f;

    [Header("Dash Settings")]
    [SerializeField] private float dashSpeed = 20f;
    [SerializeField] private float dashDuration = 0.2f;
    [SerializeField] private float dashCooldown = 1f;

    private Rigidbody2D rb;
    private Animator anim;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private bool isFacingRight = true;

    private bool isDashing = false;
    private float dashTimer;
    private float dashCooldownTimer;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
    }

    private void Update()
    {
        HandleInput();
        UpdateAnimations();

        if (isDashing)
        {
            return;
        }

        if (dashCooldownTimer > 0)
        {
            dashCooldownTimer -= Time.deltaTime;
        }
    }

    private void FixedUpdate()
    {
        if (isDashing)
        {
            rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0);
            dashTimer -= Time.fixedDeltaTime;
            if (dashTimer <= 0)
            {
                isDashing = false;
            }
            return;
        }

        CheckGroundStatus();
        HandleMovement();
        HandleJump();
    }

    private void HandleInput()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }

        if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
        {
            Dash();
        }

        HandleCharacterFlip();
    }

    private void HandleCharacterFlip()
    {
        if (moveInput > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (moveInput < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void CheckGroundStatus()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }
    }

    private void HandleMovement()
    {
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Clamp the velocity
        rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
    }

    private void HandleJump()
    {
        if (jumpBufferCounter > 0f && coyoteCounter > 0f)
        {
            Jump();
            jumpBufferCounter = 0f;
            coyoteCounter = 0f;
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
        anim.SetTrigger("Jump");
    }

    private void Dash()
    {
        isDashing = true;
        dashTimer = dashDuration;
        dashCooldownTimer = dashCooldown;
        anim.SetTrigger("Dash");
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        Vector3 scaler = transform.localScale;
        scaler.x *= -1;
        transform.localScale = scaler;
    }

    private void UpdateAnimations()
    {
        anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
        anim.SetBool("isGrounded", isGrounded);
        anim.SetBool("isDashing", isDashing);
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

New additions:

  • Dash Mechanic: Allows the player to perform a quick dash in the facing direction.
  • Dash Settings: Adjustable parameters for dash speed, duration, and cooldown.
  • Animator Integration: Triggers dash animations and manages dash state.

Testing Your Player Movement

1. Setting Up the Scene

Ensure your scene has ground objects for the player to interact with:

  1. Create ground platforms using 2D Objects:
    1. Right-click in the Hierarchy and select 2D Object > Sprite.
    2. Name it Ground.
    3. Scale and position it appropriately.
    4. Add a BoxCollider2D to enable collision.
    5. Assign the Ground layer to this GameObject.
  2. Duplicate the ground horizontally to create a floor.

2. Adjusting Player Settings

Configure the player's movement parameters in the Inspector:

  • Set Move Speed to 5.
  • Set Jump Force to 12.
  • Adjust Ground Check Radius to 0.2.
  • Configure dash settings if implemented.

3. Playtesting

Run the game and test the player controls:

  1. Press the Play button in Unity.
  2. Use the Left and Right arrow keys or A/D keys to move.
  3. Press Spacebar to jump.
  4. Press Left Shift to dash (if implemented).
  5. Ensure the player moves smoothly, jumps responsively, and interacts correctly with the ground.

4. Debugging Common Issues

If the player isn't behaving as expected, check the following:

  • Ground Check Position: Ensure the GroundCheck is correctly positioned at the player's feet.
  • Layer Assignments: Verify that ground objects are assigned to the correct Ground layer.
  • Collider Configurations: Ensure colliders are appropriately set up on both the player and ground.
  • Script Parameters: Double-check values like moveSpeed, jumpForce, and groundCheckRadius.
  • Animator Setup: Ensure the Animator parameters and transitions are correctly configured.

Advanced Features and Enhancements

1. Double Jump

Add the ability for the player to perform a second jump while airborne:

// PlayerMovement with Double Jump
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float acceleration = 10f;
    [SerializeField] private float deceleration = 10f;
    [SerializeField] private float jumpForce = 12f;
    [SerializeField] private int maxJumpCount = 2;

    [Header("Ground Detection Settings")]
    [SerializeField] private Transform groundCheck;
    [SerializeField] private float groundCheckRadius = 0.2f;
    [SerializeField] private LayerMask groundLayer;

    [Header("Jump Settings")]
    [SerializeField] private float jumpBufferTime = 0.2f;
    [SerializeField] private float coyoteTime = 0.2f;

    [Header("Dash Settings")]
    [SerializeField] private float dashSpeed = 20f;
    [SerializeField] private float dashDuration = 0.2f;
    [SerializeField] private float dashCooldown = 1f;

    private Rigidbody2D rb;
    private Animator anim;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private bool isFacingRight = true;

    private bool isDashing = false;
    private float dashTimer;
    private float dashCooldownTimer;

    private int jumpCount;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
    }

    private void Update()
    {
        HandleInput();
        UpdateAnimations();

        if (isDashing)
        {
            return;
        }

        if (dashCooldownTimer > 0)
        {
            dashCooldownTimer -= Time.deltaTime;
        }
    }

    private void FixedUpdate()
    {
        if (isDashing)
        {
            rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0);
            dashTimer -= Time.fixedDeltaTime;
            if (dashTimer <= 0)
            {
                isDashing = false;
            }
            return;
        }

        CheckGroundStatus();
        HandleMovement();
        HandleJump();
    }

    private void HandleInput()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }

        if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
        {
            Dash();
        }

        HandleCharacterFlip();
    }

    private void HandleCharacterFlip()
    {
        if (moveInput > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (moveInput < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void CheckGroundStatus()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
            jumpCount = 0;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }
    }

    private void HandleMovement()
    {
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Clamp the velocity
        rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
    }

    private void HandleJump()
    {
        if (jumpBufferCounter > 0f && (coyoteCounter > 0f || jumpCount < maxJumpCount))
        {
            Jump();
            jumpBufferCounter = 0f;
            if (!isGrounded)
            {
                jumpCount++;
            }
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
        anim.SetTrigger("Jump");
    }

    private void Dash()
    {
        isDashing = true;
        dashTimer = dashDuration;
        dashCooldownTimer = dashCooldown;
        anim.SetTrigger("Dash");
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        Vector3 scaler = transform.localScale;
        scaler.x *= -1;
        transform.localScale = scaler;
    }

    private void UpdateAnimations()
    {
        anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
        anim.SetBool("isGrounded", isGrounded);
        anim.SetBool("isDashing", isDashing);
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

Enhancements include:

  • Double Jump: Allows the player to jump a second time while airborne.
  • Jump Count: Tracks the number of jumps performed to limit double jumps.
  • Reset Jump Count: Resets when the player is grounded.

2. Implementing Variable Jump Heights

Allow the player to control jump height based on how long the jump button is held:

// PlayerMovement with Variable Jump Heights
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float acceleration = 10f;
    [SerializeField] private float deceleration = 10f;
    [SerializeField] private float jumpForce = 12f;
    [SerializeField] private float lowJumpMultiplier = 2f;
    [SerializeField] private float fallMultiplier = 2.5f;

    [Header("Ground Detection Settings")]
    [SerializeField] private Transform groundCheck;
    [SerializeField] private float groundCheckRadius = 0.2f;
    [SerializeField] private LayerMask groundLayer;

    [Header("Jump Settings")]
    [SerializeField] private float jumpBufferTime = 0.2f;
    [SerializeField] private float coyoteTime = 0.2f;

    [Header("Dash Settings")]
    [SerializeField] private float dashSpeed = 20f;
    [SerializeField] private float dashDuration = 0.2f;
    [SerializeField] private float dashCooldown = 1f;

    private Rigidbody2D rb;
    private Animator anim;
    private bool isGrounded;
    private float moveInput;

    private float jumpBufferCounter;
    private float coyoteCounter;

    private bool isFacingRight = true;

    private bool isDashing = false;
    private float dashTimer;
    private float dashCooldownTimer;

    private int jumpCount;
    [SerializeField] private int maxJumpCount = 2;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
    }

    private void Update()
    {
        HandleInput();
        UpdateAnimations();

        if (isDashing)
        {
            return;
        }

        if (dashCooldownTimer > 0)
        {
            dashCooldownTimer -= Time.deltaTime;
        }

        HandleVariableJump();
    }

    private void FixedUpdate()
    {
        if (isDashing)
        {
            rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0);
            dashTimer -= Time.fixedDeltaTime;
            if (dashTimer <= 0)
            {
                isDashing = false;
            }
            return;
        }

        CheckGroundStatus();
        HandleMovement();
        HandleJump();
    }

    private void HandleInput()
    {
        moveInput = Input.GetAxisRaw("Horizontal");

        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }
        else
        {
            jumpBufferCounter -= Time.deltaTime;
        }

        if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
        {
            Dash();
        }

        HandleCharacterFlip();
    }

    private void HandleCharacterFlip()
    {
        if (moveInput > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (moveInput < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void CheckGroundStatus()
    {
        isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
            jumpCount = 0;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }
    }

    private void HandleMovement()
    {
        float targetSpeed = moveInput * moveSpeed;
        float speedDifference = targetSpeed - rb.velocity.x;
        float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
        float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);

        rb.AddForce(movement * Vector2.right);

        // Clamp the velocity
        rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
    }

    private void HandleJump()
    {
        if (jumpBufferCounter > 0f && (coyoteCounter > 0f || jumpCount < maxJumpCount))
        {
            Jump();
            jumpBufferCounter = 0f;
            if (!isGrounded)
            {
                jumpCount++;
            }
        }
    }

    private void Jump()
    {
        rb.velocity = new Vector2(rb.velocity.x, jumpForce);
        anim.SetTrigger("Jump");
    }

    private void Dash()
    {
        isDashing = true;
        dashTimer = dashDuration;
        dashCooldownTimer = dashCooldown;
        anim.SetTrigger("Dash");
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        Vector3 scaler = transform.localScale;
        scaler.x *= -1;
        transform.localScale = scaler;
    }

    private void UpdateAnimations()
    {
        anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
        anim.SetBool("isGrounded", isGrounded);
        anim.SetBool("isDashing", isDashing);
    }

    private void HandleVariableJump()
    {
        if (rb.velocity.y > 0 && !Input.GetButton("Jump"))
        {
            rb.velocity += Vector2.up * Physics2D.gravity.y * (lowJumpMultiplier - 1) * Time.deltaTime;
        }
        else if (rb.velocity.y < 0)
        {
            rb.velocity += Vector2.up * Physics2D.gravity.y * (fallMultiplier - 1) * Time.deltaTime;
        }
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
}

Enhancements include:

  • Variable Jump Height: Adjusts the jump force based on how long the jump button is held.
  • Low Jump Multiplier: Increases gravity for shorter jumps when the jump button is released early.
  • Fall Multiplier: Increases gravity when the player is falling to make descents faster.

Advanced Techniques and Best Practices

1. Implementing Smooth Camera Follow

A smooth camera that follows the player enhances the gaming experience:

// CameraFollow.cs
using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    [Header("Target Settings")]
    public Transform target;
    public float smoothSpeed = 0.125f;
    public Vector3 offset;

    private void LateUpdate()
    {
        if (target == null)
            return;

        Vector3 desiredPosition = target.position + offset;
        Vector3 smoothedPosition = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed);
        transform.position = smoothedPosition;
    }
}

Usage:

  1. Create a new C# script named CameraFollow.
  2. Attach it to the Main Camera.
  3. Assign the Player as the Target.
  4. Adjust the Offset and Smooth Speed in the Inspector for desired camera behavior.

2. Optimizing Performance

Ensure your movement script is optimized for better performance:

  • Avoid Unnecessary Calculations: Perform calculations only when needed.
  • Reuse Variables: Minimize the creation of new variables within frequently called methods.
  • Physics Settings: Adjust Unity's physics settings to balance performance and realism.
  • Gizmos: Use Gizmos only for debugging and disable them in the final build if not necessary.

3. Modular Script Design

Design scripts to be modular for easier maintenance and scalability:

  • Separate Concerns: Divide functionalities into different scripts (e.g., Movement, Animation, Input).
  • Use Inheritance: Create base classes for shared behaviors among different player types.
  • Event Systems: Implement event systems to decouple scripts and facilitate communication.

Final Testing and Deployment

1. Comprehensive Playtesting

Thoroughly test all movement mechanics to ensure they work seamlessly:

  • Test movement in all directions.
  • Verify jumping, double jumping, and variable jump heights.
  • Ensure dash functionality works with cooldowns.
  • Check camera follow behavior.
  • Test on different platforms and resolutions.

2. Debugging and Refinement

Identify and fix any bugs or inconsistencies:

  • Use Unity's Console to monitor for errors and warnings.
  • Refine movement parameters based on playtesting feedback.
  • Optimize scripts for better performance if necessary.

3. Preparing for Deployment

Finalize your game for deployment:

  1. Disable or remove debug Gizmos.
  2. Ensure all assets are optimized.
  3. Build your game for the target platform:
    1. Go to File > Build Settings.
    2. Select the desired platform (e.g., PC, Web).
    3. Click Build and follow the prompts.

Conclusion

Creating a player movement script for a 2D side-scroller in Unity involves understanding physics interactions, handling user input, and implementing responsive controls. This comprehensive guide provided step-by-step instructions, from setting up the Unity project to writing and optimizing the movement script. By following these guidelines and continuously testing and refining your script, you can develop a fluid and engaging player movement system that enhances your game's overall experience. Remember to explore and integrate additional features like dashing, double jumping, and animations to further enrich your game's mechanics.


References


Last updated January 18, 2025
Ask Ithy AI
Download Article
Delete Article