Need something simpler? See the (basic version).
Part of these game systems:
intermediate Movement PRO

2D Player Controller Pro

Advanced 2D character controller with wall jump, wall slide, dash, double jump, ledge grab, and moving platform support.

Unity 2022.3+ · 6.2 KB · PlayerController2DPro.cs

How to Use

1

Attach to your player GameObject with Rigidbody2D + Collider2D

2

Create child empty objects for GroundCheck and WallCheck positions

3

Set Ground Layer and Wall Layer masks

4

Configure wall jump angle and force

5

Adjust dash speed, duration, and cooldown

6

Enable/disable ledge grab and set climb offset

7

Tag moving platforms as 'MovingPlatform'

8

Use one-way PlatformEffector2D — press Down+Jump to drop through

9

Hook UnityEvents for jump/dash/wall-jump SFX and particles

Source Code

PlayerController2DPro.cs
C#
using UnityEngine;
using UnityEngine.Events;

[RequireComponent(typeof(Rigidbody2D))]
public class PlayerController2DPro : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float moveSpeed = 8f;
    [SerializeField] private float acceleration = 60f;
    [SerializeField] private float deceleration = 50f;
    [SerializeField] private float airAcceleration = 40f;

    [Header("Jumping")]
    [SerializeField] private float jumpForce = 16f;
    [SerializeField] private float coyoteTime = 0.15f;
    [SerializeField] private float jumpBufferTime = 0.1f;
    [SerializeField] private float jumpCutMultiplier = 0.4f;
    [SerializeField] private int maxAirJumps = 1;

    [Header("Wall Mechanics")]
    [SerializeField] private float wallSlideSpeed = 2f;
    [SerializeField] private float wallJumpForce = 14f;
    [SerializeField] private float wallJumpAngle = 60f;
    [SerializeField] private float wallStickTime = 0.2f;
    [SerializeField] private LayerMask wallLayer;

    [Header("Dash")]
    [SerializeField] private float dashSpeed = 24f;
    [SerializeField] private float dashDuration = 0.15f;
    [SerializeField] private float dashCooldown = 0.8f;
    [SerializeField] private bool dashInAir = true;

    [Header("Ledge Grab")]
    [SerializeField] private bool enableLedgeGrab = true;
    [SerializeField] private Vector2 ledgeClimbOffset = new Vector2(0.5f, 1.2f);
    [SerializeField] private float ledgeGrabDistance = 0.5f;

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

    [Header("Wall Check")]
    [SerializeField] private Transform wallCheck;
    [SerializeField] private float wallCheckDistance = 0.4f;

    [Header("Events")]
    public UnityEvent OnJumped;
    public UnityEvent OnDashed;
    public UnityEvent OnWallJumped;
    public UnityEvent OnLedgeGrabbed;

    private Rigidbody2D rb;
    private float moveInput;
    private bool isGrounded;
    private bool isTouchingWall;
    private bool isWallSliding;
    private bool isDashing;
    private bool isLedgeGrabbing;
    private int facingDirection = 1;
    private int airJumpsRemaining;

    private float coyoteTimer;
    private float jumpBufferTimer;
    private float wallStickTimer;
    private float dashTimer;
    private float dashCooldownTimer;
    private float originalGravityScale;

    private Transform currentPlatform;
    private Vector3 lastPlatformPosition;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        rb.freezeRotation = true;
        originalGravityScale = rb.gravityScale;
    }

    private void Update()
    {
        if (isDashing || isLedgeGrabbing) return;

        moveInput = Input.GetAxisRaw("Horizontal");
        UpdateFacing();
        CheckGround();
        CheckWall();
        CheckLedge();
        HandleJumpInput();
        HandleDashInput();
        HandlePlatformDrop();
    }

    private void FixedUpdate()
    {
        if (isDashing || isLedgeGrabbing) return;

        ApplyMovement();
        ApplyWallSlide();
        ApplyPlatformMovement();
    }

    private void UpdateFacing()
    {
        if (moveInput > 0.1f) facingDirection = 1;
        else if (moveInput < -0.1f) facingDirection = -1;
    }

    private void CheckGround()
    {
        bool wasGrounded = isGrounded;
        isGrounded = groundCheck != null &&
            Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);

        if (isGrounded)
        {
            coyoteTimer = coyoteTime;
            airJumpsRemaining = maxAirJumps;
        }
        else
        {
            coyoteTimer -= Time.deltaTime;
        }

        // Platform parenting
        if (isGrounded && !wasGrounded)
        {
            RaycastHit2D hit = Physics2D.Raycast(groundCheck.position, Vector2.down, 0.5f, groundLayer);
            if (hit.collider != null && hit.collider.CompareTag("MovingPlatform"))
            {
                currentPlatform = hit.collider.transform;
                lastPlatformPosition = currentPlatform.position;
            }
        }
        else if (!isGrounded)
        {
            currentPlatform = null;
        }
    }

    private void CheckWall()
    {
        isTouchingWall = wallCheck != null &&
            Physics2D.Raycast(wallCheck.position, Vector2.right * facingDirection,
                wallCheckDistance, wallLayer);

        isWallSliding = isTouchingWall && !isGrounded && moveInput * facingDirection > 0;
    }

    private void CheckLedge()
    {
        if (!enableLedgeGrab || isGrounded || !isTouchingWall || isWallSliding) return;

        Vector2 wallPos = wallCheck != null ? (Vector2)wallCheck.position : (Vector2)transform.position;
        Vector2 upperCheck = wallPos + Vector2.up * ledgeGrabDistance;

        bool upperHit = Physics2D.Raycast(upperCheck, Vector2.right * facingDirection,
            wallCheckDistance, wallLayer);

        if (!upperHit && isTouchingWall && rb.linearVelocity.y <= 0)
        {
            StartLedgeGrab();
        }
    }

    private void StartLedgeGrab()
    {
        isLedgeGrabbing = true;
        rb.linearVelocity = Vector2.zero;
        rb.gravityScale = 0f;
        OnLedgeGrabbed?.Invoke();

        // Climb after short delay
        Invoke(nameof(CompleteLedgeClimb), 0.3f);
    }

    private void CompleteLedgeClimb()
    {
        Vector2 climbTarget = (Vector2)transform.position +
            new Vector2(facingDirection * ledgeClimbOffset.x, ledgeClimbOffset.y);
        transform.position = climbTarget;
        rb.gravityScale = originalGravityScale;
        isLedgeGrabbing = false;
    }

    private void HandleJumpInput()
    {
        if (Input.GetButtonDown("Jump"))
            jumpBufferTimer = jumpBufferTime;
        else
            jumpBufferTimer -= Time.deltaTime;

        // Variable jump height
        if (Input.GetButtonUp("Jump") && rb.linearVelocity.y > 0)
            rb.linearVelocity = new Vector2(rb.linearVelocity.x, rb.linearVelocity.y * jumpCutMultiplier);

        if (jumpBufferTimer <= 0f) return;

        // Wall jump with stick time
        if (isTouchingWall && !isGrounded)
        {
            wallStickTimer = wallStickTime;
        }

        if (wallStickTimer > 0f)
        {
            wallStickTimer -= Time.deltaTime;
            if (jumpBufferTimer > 0f)
            {
                PerformWallJump();
                wallStickTimer = 0f;
                return;
            }
        }

        // Ground jump
        if (coyoteTimer > 0f)
        {
            PerformJump();
            return;
        }

        // Air jump
        if (airJumpsRemaining > 0)
        {
            airJumpsRemaining--;
            PerformJump();
        }
    }

    private void PerformJump()
    {
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
        jumpBufferTimer = 0f;
        coyoteTimer = 0f;
        OnJumped?.Invoke();
    }

    private void PerformWallJump()
    {
        float rad = wallJumpAngle * Mathf.Deg2Rad;
        Vector2 jumpDir = new Vector2(-facingDirection * Mathf.Sin(rad), Mathf.Cos(rad));
        rb.linearVelocity = jumpDir * wallJumpForce;
        facingDirection = -facingDirection;
        jumpBufferTimer = 0f;
        coyoteTimer = 0f;
        airJumpsRemaining = maxAirJumps;
        OnWallJumped?.Invoke();
    }

    private void HandleDashInput()
    {
        dashCooldownTimer -= Time.deltaTime;

        if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
        {
            if (isGrounded || dashInAir)
                StartDash();
        }
    }

    private void StartDash()
    {
        isDashing = true;
        dashTimer = dashDuration;
        dashCooldownTimer = dashCooldown;
        rb.gravityScale = 0f;
        rb.linearVelocity = new Vector2(facingDirection * dashSpeed, 0f);
        OnDashed?.Invoke();

        Invoke(nameof(EndDash), dashDuration);
    }

    private void OnDisable()
    {
        rb.gravityScale = originalGravityScale;
        isDashing = false;
        isLedgeGrabbing = false;
        CancelInvoke();
    }

    private void EndDash()
    {
        isDashing = false;
        rb.gravityScale = originalGravityScale;
        rb.linearVelocity = new Vector2(rb.linearVelocity.x * 0.3f, 0f);
    }

    private void HandlePlatformDrop()
    {
        if (Input.GetAxisRaw("Vertical") < -0.5f && Input.GetButtonDown("Jump"))
        {
            Collider2D col = GetComponent<Collider2D>();
            if (col != null)
            {
                col.enabled = false;
                Invoke(nameof(ReenableCollider), 0.3f);
            }
        }
    }

    private void ReenableCollider()
    {
        Collider2D col = GetComponent<Collider2D>();
        if (col != null) col.enabled = true;
    }

    private void ApplyMovement()
    {
        float targetSpeed = moveInput * moveSpeed;
        float accel = isGrounded
            ? (Mathf.Abs(moveInput) > 0.01f ? acceleration : deceleration)
            : airAcceleration;

        float speedDiff = targetSpeed - rb.linearVelocity.x;
        rb.AddForce(Vector2.right * (speedDiff * accel), ForceMode2D.Force);
    }

    private void ApplyWallSlide()
    {
        if (isWallSliding && rb.linearVelocity.y < -wallSlideSpeed)
        {
            rb.linearVelocity = new Vector2(rb.linearVelocity.x, -wallSlideSpeed);
        }
    }

    private void ApplyPlatformMovement()
    {
        if (currentPlatform == null) return;
        Vector3 delta = currentPlatform.position - lastPlatformPosition;
        transform.position += delta;
        lastPlatformPosition = currentPlatform.position;
    }

    /// <summary>True if the player is currently on the ground.</summary>
    public bool IsGrounded => isGrounded;

    /// <summary>True if the player is wall sliding.</summary>
    public bool IsWallSliding => isWallSliding;

    /// <summary>True if the player is currently dashing.</summary>
    public bool IsDashing => isDashing;

    /// <summary>Current facing direction: 1 = right, -1 = left.</summary>
    public int FacingDirection => facingDirection;

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        if (groundCheck != null)
        {
            Gizmos.color = Color.green;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
        if (wallCheck != null)
        {
            Gizmos.color = Color.blue;
            Gizmos.DrawRay(wallCheck.position, Vector2.right * wallCheckDistance);
            Gizmos.DrawRay(wallCheck.position, Vector2.left * wallCheckDistance);
        }
    }
#endif
}