2D Player Controller
Complete 2D character controller with smooth movement, variable-height jumping, and coyote time.
How to Use
Attach to your player GameObject
Add a Rigidbody2D component
Create an empty child object at the feet for Ground Check
Set the Ground Layer in the inspector
Configure movement and jump values to your liking
Features
- Variable-height jumping with configurable force
- Coyote time for forgiving edge jumps
- Jump buffering for responsive input queuing
- Acceleration and deceleration for smooth movement feel
- Physics-based ground detection via OverlapCircle
- Gizmo visualization of ground check radius in Scene view
When to Use This
Perfect for 2D platformers, metroidvanias, and side-scrollers that need tight, responsive movement. Use this when you want physics-based character control with professional-feeling jump mechanics. Great starting point for any 2D game with a player character.
Common Mistakes
Forgetting to assign the groundCheck Transform in the Inspector is the #1 issue — the script will silently fail to detect ground. Make sure your ground objects are on the correct Layer matching the groundLayer mask. Don't set moveSpeed too high without increasing acceleration, or the character will feel floaty.
Source Code
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class PlayerController2D : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float moveSpeed = 8f;
[SerializeField] private float acceleration = 60f;
[SerializeField] private float deceleration = 50f;
[Header("Jumping")]
[SerializeField] private float jumpForce = 16f;
[SerializeField] private float coyoteTime = 0.15f;
[SerializeField] private float jumpBufferTime = 0.1f;
[Header("Ground Check")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckRadius = 0.2f;
[SerializeField] private LayerMask groundLayer;
private Rigidbody2D rb;
private float moveInput;
private bool isGrounded;
private float coyoteTimer;
private float jumpBufferTimer;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
rb.freezeRotation = true;
}
private void Update()
{
moveInput = Input.GetAxisRaw("Horizontal");
// Ground check
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
// Coyote time
if (isGrounded)
coyoteTimer = coyoteTime;
else
coyoteTimer -= Time.deltaTime;
// Jump buffer
if (Input.GetButtonDown("Jump"))
jumpBufferTimer = jumpBufferTime;
else
jumpBufferTimer -= Time.deltaTime;
// Jump
if (jumpBufferTimer > 0f && coyoteTimer > 0f)
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
jumpBufferTimer = 0f;
coyoteTimer = 0f;
}
// Variable jump height
if (Input.GetButtonUp("Jump") && rb.linearVelocity.y > 0f)
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, rb.linearVelocity.y * 0.5f);
}
}
private void FixedUpdate()
{
float targetSpeed = moveInput * moveSpeed;
float speedDiff = targetSpeed - rb.linearVelocity.x;
float accelRate = Mathf.Abs(targetSpeed) > 0.01f ? acceleration : deceleration;
float movement = speedDiff * accelRate * Time.fixedDeltaTime;
rb.AddForce(Vector2.right * movement, ForceMode2D.Force);
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}