Tutorial 3 of 4

Platformer Physics: The Art of Jump Feel

30 min read Advanced Playable Demo

What Makes a Great Platformer?

The difference between a frustrating platformer and an addictive one comes down to feel. Great games like Celeste, Hollow Knight, and Super Mario have incredibly tight, responsive controls.

In this tutorial, you'll learn the secrets:

Variable Jump

Hold longer = jump higher

Coyote Time

Jump briefly after leaving edge

Jump Buffer

Press jump before landing

Air Control

Move while in the air

Basic Horizontal Movement

First, let's set up smooth movement with acceleration and friction:

horizontal-movement.js
const player = {
    x: 100,
    y: 200,
    vx: 0,
    vy: 0,
    width: 32,
    height: 48,
    
    // Movement constants
    moveSpeed: 5,
    acceleration: 0.8,
    friction: 0.85
};

const keys = {};

document.addEventListener('keydown', e => keys[e.code] = true);
document.addEventListener('keyup', e => keys[e.code] = false);

function updateMovement() {
    // Get input direction
    let inputX = 0;
    if (keys['ArrowLeft'] || keys['KeyA']) inputX = -1;
    if (keys['ArrowRight'] || keys['KeyD']) inputX = 1;
    
    // Apply acceleration
    if (inputX !== 0) {
        player.vx += inputX * player.acceleration;
        player.vx = Math.max(-player.moveSpeed, 
                    Math.min(player.moveSpeed, player.vx));
    } else {
        // Apply friction when no input
        player.vx *= player.friction;
        if (Math.abs(player.vx) < 0.1) player.vx = 0;
    }
    
    // Update position
    player.x += player.vx;
}
Pro Tip

The ratio between acceleration and friction controls how "slippery" the movement feels. Higher friction = more responsive stops.

Variable Height Jumping

In most great platformers, how long you hold the jump button affects jump height. This gives players precise control.

if (released early) → cut velocity in half
Simple but effective variable jump technique
variable-jump.js
const player = {
    // ... previous properties
    jumpForce: -12,
    gravity: 0.6,
    isGrounded: false,
    isJumping: false
};

function updateJump() {
    // Apply gravity
    player.vy += player.gravity;
    
    // Jump initiation
    if (keys['Space'] && player.isGrounded && !player.isJumping) {
        player.vy = player.jumpForce;
        player.isGrounded = false;
        player.isJumping = true;
    }
    
    // Variable jump height - cut velocity when released
    if (!keys['Space'] && player.isJumping && player.vy < 0) {
        player.vy *= 0.5;  // Cut upward velocity
        player.isJumping = false;
    }
    
    // Reset jump flag when grounded
    if (player.isGrounded) {
        player.isJumping = false;
    }
    
    player.y += player.vy;
}
Game Design Note

The 0.5 multiplier determines how much "short hop" you get. Lower values (0.3-0.4) give more control, higher values (0.6-0.7) feel less responsive.

Coyote Time

Coyote Time (named after Wile E. Coyote running off cliffs) lets players jump for a few frames after leaving a platform. This makes the game feel more forgiving.

coyote-time.js
const player = {
    // ... previous properties
    coyoteTime: 6,        // Frames of grace period
    coyoteCounter: 0      // Current counter
};

function updateCoyoteTime() {
    if (player.isGrounded) {
        player.coyoteCounter = player.coyoteTime;
    } else {
        player.coyoteCounter--;
    }
}

function canJump() {
    // Can jump if grounded OR within coyote time
    return player.coyoteCounter > 0;
}

function updateJump() {
    updateCoyoteTime();
    
    // Use canJump() instead of isGrounded
    if (keys['Space'] && canJump() && !player.isJumping) {
        player.vy = player.jumpForce;
        player.coyoteCounter = 0;  // Reset to prevent double jump
        player.isJumping = true;
    }
    
    // ... rest of jump code
}
Typical Values

Celeste uses about 5-6 frames (~100ms). Less forgiving games use 3-4 frames. More forgiving games can use up to 10 frames.

Jump Buffering

Jump buffering remembers if you pressed jump slightly before landing. When you land, the jump executes automatically. This prevents frustrating "missed" jumps.

jump-buffer.js
const player = {
    // ... previous properties
    jumpBufferTime: 8,     // Frames to remember jump press
    jumpBufferCounter: 0   // Current counter
};

function updateJumpBuffer() {
    if (keys['Space']) {
        player.jumpBufferCounter = player.jumpBufferTime;
    } else {
        player.jumpBufferCounter--;
    }
}

function updateJump() {
    updateCoyoteTime();
    updateJumpBuffer();
    
    // Jump if we have both buffer AND ability to jump
    if (player.jumpBufferCounter > 0 && canJump()) {
        player.vy = player.jumpForce;
        player.jumpBufferCounter = 0;
        player.coyoteCounter = 0;
        player.isJumping = true;
    }
    
    // Variable jump
    if (!keys['Space'] && player.isJumping && player.vy < 0) {
        player.vy *= 0.5;
        player.isJumping = false;
    }
    
    player.vy += player.gravity;
    player.y += player.vy;
}

Interactive Demo

Try the platformer controls! All the techniques from this tutorial are implemented:

Platformer Physics Demo
or AD Move Space Jump (hold for higher) or W Also Jump

Air Control

Should players have full control in the air, or limited? This is a design choice:

air-control.js
const player = {
    // ... previous properties
    airControl: 0.6  // 0 = no control, 1 = full control
};

function updateMovement() {
    let inputX = 0;
    if (keys['ArrowLeft'] || keys['KeyA']) inputX = -1;
    if (keys['ArrowRight'] || keys['KeyD']) inputX = 1;
    
    // Reduce acceleration in air
    let accel = player.acceleration;
    if (!player.isGrounded) {
        accel *= player.airControl;
    }
    
    if (inputX !== 0) {
        player.vx += inputX * accel;
        player.vx = Math.max(-player.moveSpeed, 
                    Math.min(player.moveSpeed, player.vx));
    } else {
        // Less friction in air (optional)
        let fric = player.isGrounded ? player.friction : 0.95;
        player.vx *= fric;
    }
    
    player.x += player.vx;
}
Design Choices

Celeste: Full air control (1.0) - very responsive
Super Mario: Limited air control (~0.5) - more committed jumps
Realistic: No air control (0) - hardcore difficulty

Complete Platformer Controller

platformer-controller.js
class PlatformerPlayer {
    constructor(x, y) {
        this.x = x;
        this.y = y;
        this.vx = 0;
        this.vy = 0;
        this.width = 32;
        this.height = 48;
        
        // Movement
        this.moveSpeed = 5;
        this.acceleration = 0.8;
        this.friction = 0.85;
        this.airControl = 0.6;
        
        // Jumping
        this.jumpForce = -12;
        this.gravity = 0.6;
        this.isGrounded = false;
        this.isJumping = false;
        
        // Coyote time
        this.coyoteTime = 6;
        this.coyoteCounter = 0;
        
        // Jump buffer
        this.jumpBufferTime = 8;
        this.jumpBufferCounter = 0;
    }
    
    update(keys, platforms) {
        this.updateTimers(keys);
        this.updateMovement(keys);
        this.updateJump(keys);
        this.applyGravity();
        this.handleCollisions(platforms);
    }
    
    updateTimers(keys) {
        // Coyote time
        if (this.isGrounded) {
            this.coyoteCounter = this.coyoteTime;
        } else {
            this.coyoteCounter--;
        }
        
        // Jump buffer
        if (keys['Space'] || keys['ArrowUp'] || keys['KeyW']) {
            this.jumpBufferCounter = this.jumpBufferTime;
        } else {
            this.jumpBufferCounter--;
        }
    }
    
    updateMovement(keys) {
        let inputX = 0;
        if (keys['ArrowLeft'] || keys['KeyA']) inputX = -1;
        if (keys['ArrowRight'] || keys['KeyD']) inputX = 1;
        
        let accel = this.isGrounded ? this.acceleration : 
                    this.acceleration * this.airControl;
        
        if (inputX !== 0) {
            this.vx += inputX * accel;
            this.vx = Math.max(-this.moveSpeed, 
                        Math.min(this.moveSpeed, this.vx));
        } else {
            this.vx *= this.friction;
            if (Math.abs(this.vx) < 0.1) this.vx = 0;
        }
        
        this.x += this.vx;
    }
    
    updateJump(keys) {
        const jumpKey = keys['Space'] || keys['ArrowUp'] || keys['KeyW'];
        
        // Jump if buffered and can jump
        if (this.jumpBufferCounter > 0 && this.coyoteCounter > 0) {
            this.vy = this.jumpForce;
            this.jumpBufferCounter = 0;
            this.coyoteCounter = 0;
            this.isGrounded = false;
            this.isJumping = true;
        }
        
        // Variable jump
        if (!jumpKey && this.isJumping && this.vy < 0) {
            this.vy *= 0.5;
            this.isJumping = false;
        }
    }
    
    applyGravity() {
        this.vy += this.gravity;
        this.y += this.vy;
    }
    
    handleCollisions(platforms) {
        this.isGrounded = false;
        
        for (const plat of platforms) {
            if (this.intersects(plat)) {
                // Landing on top
                if (this.vy > 0 && 
                    this.y + this.height - this.vy <= plat.y) {
                    this.y = plat.y - this.height;
                    this.vy = 0;
                    this.isGrounded = true;
                    this.isJumping = false;
                }
            }
        }
    }
    
    intersects(rect) {
        return this.x < rect.x + rect.width &&
               this.x + this.width > rect.x &&
               this.y < rect.y + rect.height &&
               this.y + this.height > rect.y;
    }
}

Challenges

  1. Double Jump: Add a second jump in mid-air
  2. Wall Slide: Slide down walls when pressing into them
  3. Wall Jump: Jump off walls in the opposite direction
  4. Dash: Quick burst of speed in any direction
Next Steps

In the final tutorial, we'll add visual polish with Particle Systems – dust clouds when landing, trail effects when dashing, and more!