Unity 2D Platformer Movement: Coyote Time, Jump Buffering and Better Jumps

Illustration of a character jumping between platforms

Platformer movement is where many Unity developers write their first real gameplay code, and it’s surprisingly easy to make it feel bad. A character that slides on ice, jumps the same height no matter how long you hold the button, or ignores a jump pressed a fraction of a second before landing feels “off” even if players can’t say why. This Unity 2D platformer movement guide builds a Rigidbody2D controller step by step, then adds the small tricks that make commercial platformers feel responsive: coyote time, jump buffering, variable jump height and better gravity.

Watch it in action: coyote time, jump buffering and better gravity side by side (4:15).


Scene and Rigidbody2D Setup

Create a player sprite and add these components:

  • Rigidbody2D: Body Type Dynamic, Collision Detection Continuous, Interpolate Interpolate, and tick Freeze Rotation Z so the player doesn’t topple over.
  • CapsuleCollider2D: a capsule’s rounded bottom slides over small seams between tiles, whereas a box collider snags on them.

Give the ground a collider too (a TilemapCollider2D plus CompositeCollider2D works well, see our Tilemap and Rule Tile tutorial). Put the ground on a layer called Ground. We’ll use it for the ground check.

Finally, create a Physics Material 2D with Friction 0 and assign it to the player’s collider. Without it, the player sticks to walls when you hold a direction into them mid-air.

Unity Inspector showing a Rigidbody2D with Collision Detection Continuous, Interpolate set to Interpolate and Freeze Rotation Z ticked.
The Rigidbody2D settings from this guide: Continuous collision detection, Interpolate, and Freeze Rotation Z.

Horizontal Movement

Read input in Update and apply velocity in FixedUpdate. This example uses the Input System with an action asset that has a Move (Vector2) and Jump (Button) action. If you haven’t set that up, our new Input System guide covers it.

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D))]
public class PlatformerController : MonoBehaviour
{
    [SerializeField] float moveSpeed = 8f;
    [SerializeField] InputActionReference moveAction;

    Rigidbody2D rb;
    float inputX;

    void Awake() => rb = GetComponent<Rigidbody2D>();

    void Update()
    {
        inputX = moveAction.action.ReadValue<Vector2>().x;
    }

    void FixedUpdate()
    {
        rb.linearVelocity = new Vector2(inputX * moveSpeed, rb.linearVelocity.y);
    }
}

Note linearVelocity: in Unity 6 the old velocity property is obsolete. Keeping the existing y velocity is important. Overwriting it with zero would cancel gravity and jumping.

A Reliable Ground Check

Most “can’t jump” and “double jump” bugs come from a bad ground check. Don’t use OnCollisionEnter2D with a bool, because touching a wall counts as a collision too. Instead, check a small box just under the feet:

[SerializeField] LayerMask groundMask;
[SerializeField] Vector2 groundBoxSize = new Vector2(0.5f, 0.05f);
[SerializeField] float groundBoxOffset = 0.02f;
CapsuleCollider2D col;

bool IsGrounded()
{
    Vector2 feet = new Vector2(col.bounds.center.x, col.bounds.min.y - groundBoxOffset);
    return Physics2D.OverlapBox(feet, groundBoxSize, 0f, groundMask);
}

void OnDrawGizmosSelected()
{
    if (col == null) col = GetComponent<CapsuleCollider2D>();
    Gizmos.color = Color.green;
    Gizmos.DrawWireCube(new Vector3(col.bounds.center.x, col.bounds.min.y - groundBoxOffset), groundBoxSize);
}

Make the box slightly narrower than the collider. If it’s as wide as the player, it touches walls you are pressed against and reports “grounded” while you slide down them. The gizmo lets you see it in the Scene view. A raycast also works (see our raycast tutorial), but a single centre ray misses when the player stands on a ledge edge.

Two Unity players sliding down a wall: a 0.9 wide ground box reports IsGrounded True against the wall, a 0.5 wide box stays False.
A ground box as wide as the player touches the wall and reports grounded (left); a narrower box does not (right).

Jumping

Set the vertical velocity directly instead of adding force. It gives exactly the same jump height every time, regardless of whether you were falling slightly:

[SerializeField] float jumpVelocity = 14f;
[SerializeField] InputActionReference jumpAction;

void Update()
{
    inputX = moveAction.action.ReadValue<Vector2>().x;
    if (jumpAction.action.WasPressedThisFrame() && IsGrounded())
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpVelocity);
}

Want to design jumps by height instead of velocity? Use v = sqrt(2 * g * h): jumpVelocity = Mathf.Sqrt(2f * Mathf.Abs(Physics2D.gravity.y * rb.gravityScale) * jumpHeight).

Coyote Time and Jump Buffering

These two tricks make the controller forgive human timing:

  • Coyote time lets the player jump for a short window (around 0.1 s) after running off a ledge. Without it, players press jump “on the edge” and nothing happens.
  • Jump buffering remembers a jump pressed just before landing and performs it the moment the player touches down.
[SerializeField] float coyoteTime = 0.1f;
[SerializeField] float jumpBufferTime = 0.12f;
float coyoteTimer, bufferTimer;

void Update()
{
    inputX = moveAction.action.ReadValue<Vector2>().x;

    coyoteTimer = IsGrounded() ? coyoteTime : coyoteTimer - Time.deltaTime;
    bufferTimer = jumpAction.action.WasPressedThisFrame() ? jumpBufferTime : bufferTimer - Time.deltaTime;

    if (bufferTimer > 0f && coyoteTimer > 0f)
    {
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpVelocity);
        bufferTimer = 0f;
        coyoteTimer = 0f;   // prevents a second jump during the coyote window
    }
}

Zeroing both timers after the jump is what stops a sneaky double jump.

Two Unity players pressing jump 0.07 seconds after running off a ledge: without coyote time the press is ignored, with 0.1 s of coyote time it jumps.
The same late jump press: ignored without coyote time (left), a clean jump with 0.1 s of coyote time (right).

Variable Jump Height and Better Falling

A floaty jump comes from symmetrical gravity: the player rises and falls at the same rate. Good platformers fall faster than they rise, and cut the jump short when the button is released.

[SerializeField] float fallGravityMultiplier = 2f;
[SerializeField] float lowJumpMultiplier = 3f;
[SerializeField] float maxFallSpeed = 20f;
float baseGravity;

void Awake()
{
    rb = GetComponent<Rigidbody2D>();
    col = GetComponent<CapsuleCollider2D>();
    baseGravity = rb.gravityScale;
}

void FixedUpdate()
{
    rb.linearVelocity = new Vector2(inputX * moveSpeed, rb.linearVelocity.y);

    if (rb.linearVelocity.y < 0f)
        rb.gravityScale = baseGravity * fallGravityMultiplier;
    else if (rb.linearVelocity.y > 0f && !jumpAction.action.IsPressed())
        rb.gravityScale = baseGravity * lowJumpMultiplier;
    else
        rb.gravityScale = baseGravity;

    if (rb.linearVelocity.y < -maxFallSpeed)
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, -maxFallSpeed);
}

Tapping jump now gives a hop, holding it gives a full jump, and the fall feels weighty. The max fall speed clamp stops the player tunnelling through thin platforms after long drops.

Dotted jump arcs in Unity: symmetric gravity stays in the air 0.92 seconds, a fall gravity multiplier of 2 lands after 0.78 seconds.
Symmetric gravity hangs in the air (left); doubling gravity on the way down lands sooner and feels weightier (right).

Acceleration and Deceleration

Instant full speed feels twitchy. Instant stopping is usually fine for precise platformers, but a little acceleration adds weight. Use Mathf.MoveTowards on the x velocity:

[SerializeField] float acceleration = 70f;
[SerializeField] float deceleration = 90f;
[SerializeField] float airControl = 0.6f;

void ApplyHorizontal()
{
    float target = inputX * moveSpeed;
    float rate = Mathf.Abs(target) > 0.01f ? acceleration : deceleration;
    if (!IsGrounded()) rate *= airControl;
    float x = Mathf.MoveTowards(rb.linearVelocity.x, target, rate * Time.fixedDeltaTime);
    rb.linearVelocity = new Vector2(x, rb.linearVelocity.y);
}
Feel Acceleration Deceleration Air control
Tight, precise (Celeste-like) 90+ 120+ 0.8–1
Balanced 60–80 80–100 0.6
Heavy, momentum-based 20–35 25–40 0.3–0.5

🎮 Skip the boilerplate?

If you’d rather spend time on level design than controller code, a few Asset Store controllers are well worth the price. We list the ones indie devs actually use.

See our favourite Unity tools →

Three Unity platformer lanes with tight, balanced and heavy acceleration and deceleration values from the feel table.
Tight, balanced and heavy settings from the table: heavy slides about 1 m after release, tight about 0.25 m.

The Full Script

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D), typeof(CapsuleCollider2D))]
public class PlatformerController : MonoBehaviour
{
    [Header("Input")]
    [SerializeField] InputActionReference moveAction, jumpAction;
    [Header("Run")]
    [SerializeField] float moveSpeed = 8f, acceleration = 70f, deceleration = 90f, airControl = 0.6f;
    [Header("Jump")]
    [SerializeField] float jumpVelocity = 14f, coyoteTime = 0.1f, jumpBufferTime = 0.12f;
    [SerializeField] float fallGravityMultiplier = 2f, lowJumpMultiplier = 3f, maxFallSpeed = 20f;
    [Header("Ground")]
    [SerializeField] LayerMask groundMask;
    [SerializeField] Vector2 groundBoxSize = new Vector2(0.5f, 0.05f);

    Rigidbody2D rb;
    CapsuleCollider2D col;
    float inputX, coyoteTimer, bufferTimer, baseGravity;
    bool grounded;

    void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        col = GetComponent<CapsuleCollider2D>();
        baseGravity = rb.gravityScale;
    }

    void OnEnable() { moveAction.action.Enable(); jumpAction.action.Enable(); }

    void Update()
    {
        inputX = moveAction.action.ReadValue<Vector2>().x;
        grounded = Physics2D.OverlapBox(new Vector2(col.bounds.center.x, col.bounds.min.y - 0.02f), groundBoxSize, 0f, groundMask);

        coyoteTimer = grounded ? coyoteTime : coyoteTimer - Time.deltaTime;
        bufferTimer = jumpAction.action.WasPressedThisFrame() ? jumpBufferTime : bufferTimer - Time.deltaTime;

        if (bufferTimer > 0f && coyoteTimer > 0f)
        {
            rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpVelocity);
            bufferTimer = coyoteTimer = 0f;
        }
    }

    void FixedUpdate()
    {
        float target = inputX * moveSpeed;
        float rate = (Mathf.Abs(target) > 0.01f ? acceleration : deceleration) * (grounded ? 1f : airControl);
        float vx = Mathf.MoveTowards(rb.linearVelocity.x, target, rate * Time.fixedDeltaTime);

        if (rb.linearVelocity.y < 0f) rb.gravityScale = baseGravity * fallGravityMultiplier;
        else if (rb.linearVelocity.y > 0f && !jumpAction.action.IsPressed()) rb.gravityScale = baseGravity * lowJumpMultiplier;
        else rb.gravityScale = baseGravity;

        rb.linearVelocity = new Vector2(vx, Mathf.Max(rb.linearVelocity.y, -maxFallSpeed));
    }
}

To flip the sprite, set spriteRenderer.flipX = inputX < 0 whenever inputX isn’t zero. To drive animations, pass Mathf.Abs(rb.linearVelocity.x) and grounded into Animator parameters. If they don’t play, see Animator not playing: fixes.

Troubleshooting

The player sticks to walls in mid-air

Friction between the player and the wall is holding it up. Assign a Physics Material 2D with Friction 0 to the player’s collider (and keep friction on the ground if you want).

The player can jump infinitely / double jumps

The ground check overlaps the player’s own collider, or touches walls. Make sure the player isn’t on the Ground layer, and narrow the check box. Also zero the coyote timer after jumping.

Jump input is sometimes ignored

You’re reading WasPressedThisFrame inside FixedUpdate. FixedUpdate doesn’t run every frame, so presses get lost. Read input in Update, as above.

The player catches on tile edges

Each tile has its own collider. Add a CompositeCollider2D to the Tilemap (set its Rigidbody2D to Static) and tick Used By Composite on the TilemapCollider2D. A capsule collider helps as well.

Movement is jittery

Turn on Interpolate on the Rigidbody2D and never move the player with transform.position. Our Rigidbody jitter fix covers camera-related jitter too.

Two Unity players holding into a wall mid-air: with friction 0.4 the player is stuck at vy 0, with a Friction 0 Physics Material 2D it slides down.
Friction holds the player on the wall (left); a Physics Material 2D with Friction 0 lets it slide (right).

FAQ

Should I use Rigidbody2D or a kinematic controller for a platformer?

Rigidbody2D is easiest and works for most games. Kinematic controllers that do their own collision with casts give pixel-perfect control and are common in precision platformers, but take much more code.

What is coyote time?

A short grace period, usually 0.08 to 0.15 seconds, during which the player can still jump after walking off a ledge. It makes jumps feel fair.

Why use linearVelocity instead of AddForce for jumping?

Setting velocity gives a consistent jump height regardless of current vertical speed. AddForce adds to whatever velocity already exists, so jumps vary.

How do I stop the player sliding down slopes?

When grounded with no input, set gravityScale to 0 and zero the velocity, or use a high-friction material while idle and zero friction while moving.

Can I use the old Input class instead?

Yes, if Active Input Handling is set to Both or Input Manager. Replace the actions with Input.GetAxisRaw(“Horizontal”) and Input.GetButtonDown(“Jump”).

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *