2D Player Controller Pro
Advanced 2D character controller with wall jump, wall slide, dash, double jump, ledge grab, and moving platform support.
How to Use
Attach to your player GameObject with Rigidbody2D + Collider2D
Create child empty objects for GroundCheck and WallCheck positions
Set Ground Layer and Wall Layer masks
Configure wall jump angle and force
Adjust dash speed, duration, and cooldown
Enable/disable ledge grab and set climb offset
Tag moving platforms as 'MovingPlatform'
Use one-way PlatformEffector2D — press Down+Jump to drop through
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
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
}