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

Features

  • Coyote time and jump buffering for forgiving platformer feel
  • Wall slide, wall jump with configurable angle, and wall stick time
  • Air dash with cooldown and optional air-only restriction
  • Ledge grab detection and automatic climb-up animation
  • Moving platform support via parenting with delta tracking
  • Variable jump height via jump-cut multiplier on button release

When to Use This

The go-to controller for polished 2D platformers — Metroidvanias, precision platformers, and Celeste-style games. Use this instead of the basic 2D controller when you need tight, responsive movement with wall mechanics and dashing. Pair with Camera Follow 2D for smooth camera tracking.

Common Mistakes

You must create GroundCheck and WallCheck child Transforms and position them at the character's feet and side — the script won't detect ground or walls without them. Set groundLayer and wallLayer to your terrain layers, not 'Everything', or the player will detect itself. The ledge grab checks for empty space above the wall, so wall colliders must not extend to the ceiling.

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
}