Platformer Physics: The Art of Jump Feel
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:
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;
}
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.
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;
}
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.
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
}
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.
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:
Air Control
Should players have full control in the air, or limited? This is a design choice:
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;
}
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
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
- Double Jump: Add a second jump in mid-air
- Wall Slide: Slide down walls when pressing into them
- Wall Jump: Jump off walls in the opposite direction
- Dash: Quick burst of speed in any direction
In the final tutorial, we'll add visual polish with Particle Systems – dust clouds when landing, trail effects when dashing, and more!