# Godot 4 — Movement Feel 2D

> **Purpose**: Reference document for Claude Code. The complete 2D platformer movement toolkit — coyote time, input buffering, acceleration curves, variable jump, corner correction — with specific values from Celeste, Hollow Knight, Dead Cells, and Ori. The marinade that makes movement feel "right."

---

## 1. Speed Curves by Game (Reference Table)

All values from the Celeste open-source Player.cs, Hollow Knight fan analysis, and community reverse-engineering. Celeste runs at 60fps with a 320x180 virtual resolution. Hollow Knight and Dead Cells run at higher resolutions with physics-based movement.

| Parameter | Celeste | Hollow Knight | Dead Cells | Ori |
|-----------|---------|--------------|-----------|-----|
| Max run speed | 90 | ~350 | ~280 | ~400 |
| Run acceleration | 1000 | Instant (no accel) | ~1200 | ~1800 |
| Run deceleration | 400 | Instant (no decel) | ~800 | ~1400 |
| Air multiplier | 0.65x | ~0.85x | ~0.70x | ~0.90x |
| Gravity | 900 | ~2000 | ~1500 | ~1200 |
| Max fall speed | 160 | ~500 | ~400 | ~350 |
| Fast fall speed | 240 | ~700 | -- | -- |
| Jump velocity | -105 | ~-600 | ~-500 | ~-450 |
| Dash speed | 240 | ~550 | ~500 | ~600 |
| Coyote time | 100ms (6f) | ~83ms (5f) | ~67ms (4f) | ~100ms (6f) |
| Jump buffer | 83ms (5f) | ~67ms (4f) | ~67ms (4f) | ~83ms (5f) |
| Var jump time | 200ms | ~150ms | ~120ms | ~180ms |

**Resolution context**: Celeste's values are for a 320x180 canvas. At 1080p (6x), multiply visual distances by 6. A `MaxRun` of 90 in Celeste-space is equivalent to ~540 px/s at 1080p. Scale accordingly for your project's resolution.

**Celeste's key insight**: `RunAccel` (1000) is 11x `MaxRun` (90). That means 0-to-max in ~5 frames (~83ms). This is why Celeste feels instant despite technically having acceleration. The player never perceives the ramp because it's over before their brain registers it.

**Hollow Knight's key insight**: zero acceleration, zero deceleration. The Knight is either at full speed or stopped. This creates the deliberate, precise feel — every directional input commitment is absolute.

---

## 2. Coyote Time

### What the Player Feels

They walked off a ledge and still jumped. They don't know they were technically airborne. The game felt fair.

### Values From Shipped Games

| Game | Duration | Notes |
|------|----------|-------|
| Celeste | 100ms (6 frames) | Called `JumpGraceTime` in source |
| Hollow Knight | ~83ms (5 frames) | Shorter — movement is more committal |
| Dead Cells | ~67ms (4 frames) | Combat-focused, less forgiveness needed |
| Ori | ~100ms (6 frames) | Generous, matches the fluid aesthetic |
| Super Mario Bros | 0ms | No coyote time — NES era, different philosophy |

### The Maddy Thorson Forgiveness Philosophy

All these mechanics widen timing/positioning windows so everything is fudged a tiny bit in the player's favor. This is why Celeste can feel kind even though it's very difficult. Coyote time is the most impactful single forgiveness mechanic. 100ms feels generous without being exploitable.

### Implementation Pattern

The key: track "was on floor" state from the PREVIOUS frame. When the character steps off a ledge (was on floor, now isn't, AND didn't jump), start the coyote timer. The player can still jump during this window.

**Critical distinction**: coyote time activates when you WALK off an edge, not when you jump and are falling. If the player jumped, coyote time should not apply (they already used their jump).

```gdscript
## Coyote time variables — add to your CharacterBody2D
var _was_on_floor: bool = false
var _coyote_timer: float = 0.0
const COYOTE_TIME := 0.1  ## 100ms — Celeste value


func _physics_process(delta: float) -> void:
	var on_floor: bool = is_on_floor()

	# Coyote time: just walked off an edge (didn't jump)
	if _was_on_floor and not on_floor and velocity.y >= 0.0:
		_coyote_timer = COYOTE_TIME

	if _coyote_timer > 0.0:
		_coyote_timer -= delta

	_was_on_floor = on_floor

	# In the jump check:
	var can_jump: bool = on_floor or _coyote_timer > 0.0
```

**Why `velocity.y >= 0.0`**: if the character has upward velocity, they jumped — don't grant coyote time. This guard prevents double-jump exploits where the player jumps, hits a ceiling, falls, and gets a free coyote jump.

---

## 3. Input Buffering

### What the Player Feels

They pressed jump a split second before landing and the character jumped immediately on touchdown. The game read their mind.

### Values From Shipped Games

| Game | Buffer Window | Notes |
|------|-------------|-------|
| Celeste | 83ms (5 frames) | Jump buffer, also used for dashes |
| Hollow Knight | ~67ms (4 frames) | Jump + attack buffering |
| Dead Cells | ~67ms (4 frames) | Roll + attack + jump |
| General sweet spot | 80-100ms | Below 50ms = too tight. Above 150ms = inputs feel delayed |

### Implementation Pattern

Record WHEN the player pressed jump (not just IF). When the character lands, check if a jump was buffered within the window. If yes, execute it immediately.

This also works for attack buffering during recovery animations, dodge buffering during hitstun, and any action that should queue while the player can't act.

```gdscript
## Input buffer variables
var _jump_buffer_timer: float = 0.0
const JUMP_BUFFER_TIME := 0.1  ## 100ms


func _physics_process(delta: float) -> void:
	# Record jump input
	if Input.is_action_just_pressed("jump"):
		_jump_buffer_timer = JUMP_BUFFER_TIME

	if _jump_buffer_timer > 0.0:
		_jump_buffer_timer -= delta

	# Check: can we execute the buffered jump?
	if _jump_buffer_timer > 0.0 and is_on_floor():
		_execute_jump()
		_jump_buffer_timer = 0.0  # Consume the buffer
```

### Coyote + Buffer Interaction

These two systems complement each other but must not stack into exploits.

**The dream jump window** (Celeste term): coyote time extends jump availability AFTER leaving ground. Buffer extends jump input BEFORE touching ground. Together they create a wide timing window. In Celeste, dream jumps have a combined 13-frame window: 5 buffer frames + exit frame + 3 freeze frames + 5 coyote frames.

**Guard against double-fire**: when a buffered jump executes, clear BOTH the buffer AND the coyote timer. Otherwise the player can coyote-jump and then immediately buffer-jump on landing for an unintended double jump.

---

## 4. Acceleration Curves

### What the Player Feels

**Snappy** (Celeste): press right, you're at full speed. Release, you stop. Feels like you ARE the character — zero delay between thought and action.

**Weighty** (Hollow Knight): press right, you're at full speed instantly. Release, you stop instantly. Even snappier than Celeste — zero acceleration at all.

**Slippery** (Super Meat Boy): you accelerate fast, decelerate slow. Momentum carries you. The floor is alive and you're negotiating with it.

### The Acceleration Spectrum

| Feel | Accel Time (0 to max) | Decel Time (max to 0) | Games |
|------|----------------------|----------------------|-------|
| Instant | 0ms | 0ms | Hollow Knight |
| Near-instant | 50-80ms | 80-120ms | Celeste (83ms accel) |
| Responsive | 80-120ms | 100-150ms | Dead Cells, Ori |
| Weighty | 150-250ms | 200-350ms | Mega Man X, Castlevania |
| Floaty | 300-500ms+ | 400-600ms+ | Early Mario, LittleBigPlanet |

**Celeste's numbers**: `RunAccel = 1000`, `RunReduce = 400`, `MaxRun = 90`. Time to max speed: 90/1000 = 0.09s (90ms). Time to stop: 90/400 = 0.225s (225ms). Acceleration is 2.5x faster than deceleration — you START fast but STOP with a tiny slide. This micro-slide makes the character feel physical without being floaty.

**The "responsive illusion"**: Celeste has 5-frame acceleration but feels instant. Why? Because 83ms is below the human reaction threshold (~150-200ms). The player presses a direction, and by the time their brain registers the visual change, the character is already at full speed. Anything below ~100ms feels "instant" to most players.

### Acceleration Formula

The key: asymmetric acceleration. Faster to start, slower to stop. This creates the "responsive but physical" feel.

```gdscript
const MAX_SPEED := 300.0
const ACCELERATION := 2000.0   ## ~150ms to max speed (300/2000)
const DECELERATION := 1200.0   ## ~250ms to stop (300/1200)
const AIR_MULTIPLIER := 0.65   ## Air control = 65% of ground


func _physics_process(delta: float) -> void:
	var input_dir: float = Input.get_axis("move_left", "move_right")
	var accel: float

	if input_dir != 0.0:
		# Accelerating
		accel = ACCELERATION
		if not is_on_floor():
			accel *= AIR_MULTIPLIER
		# Higher acceleration when changing direction (turn boost)
		if signf(input_dir) != signf(velocity.x) and absf(velocity.x) > 10.0:
			accel *= 1.5
		velocity.x = move_toward(velocity.x, input_dir * MAX_SPEED, accel * delta)
	else:
		# Decelerating
		velocity.x = move_toward(velocity.x, 0.0, DECELERATION * delta)
```

### Turn Responsiveness

**Instant turn** (Celeste, Hollow Knight): direction changes are immediate. No turn animation, no momentum bleed. Press left while going right = you're going left next frame. This is what makes precision platforming possible.

**Momentum turn** (Super Meat Boy): changing direction while moving requires decelerating to zero first, then accelerating in the new direction. The character slides before reversing. Feels slippery, skate-like.

**The turn boost**: in the code above, acceleration is 1.5x when the player inputs opposite to current velocity. This makes direction changes feel snappier than acceleration from standstill, without being truly instant. The character "pushes off" harder when reversing.

---

## 5. Variable Jump Height

### What the Player Feels

Tap jump = short hop. Hold jump = full height. The character responds to how hard you "press." This illusion is fundamental — without it, every jump is the same height, and the player feels like they're operating a catapult instead of a body.

### Values From Shipped Games

| Game | Technique | Short/Full Ratio | Notes |
|------|-----------|-----------------|-------|
| Celeste | Half gravity during ascent while holding jump, `VarJumpTime = 0.2s` | ~40% min / 100% max | Gravity is halved when `velocity.y < HalfGravThreshold (40)` AND jump held |
| Hollow Knight | Jump cancel on release | ~35% min / 100% max | Release button = immediate cancel, gravity takes over |
| Mario | Reduced upward velocity on release | ~50% min / 100% max | Classic approach |
| Dead Cells | Gravity multiplier on release | ~45% min / 100% max | Quick cutoff |

### The Two Approaches

**Approach 1: Gravity Multiplier on Release** (Dead Cells, general-purpose)

When the player releases jump during ascent, multiply gravity by 2-3x. The character decelerates faster and peaks sooner.

```gdscript
const JUMP_VELOCITY := -400.0
const GRAVITY := 1200.0
const FALL_GRAVITY_MULT := 2.0    ## Gravity multiplier when falling
const SHORT_JUMP_MULT := 2.5      ## Gravity multiplier when jump released early

var _jumping: bool = false


func _physics_process(delta: float) -> void:
	var gravity: float = GRAVITY

	if velocity.y > 0.0:
		# Falling — heavier gravity for snappier descent
		gravity *= FALL_GRAVITY_MULT
		_jumping = false
	elif velocity.y < 0.0 and _jumping and not Input.is_action_pressed("jump"):
		# Ascending but jump released — cut the jump short
		gravity *= SHORT_JUMP_MULT

	velocity.y += gravity * delta
	velocity.y = minf(velocity.y, MAX_FALL_SPEED)
```

**Approach 2: Variable Jump Time** (Celeste style)

Celeste uses `VarJumpTime = 0.2s` — during this window, if the player holds jump, gravity is halved. After the window closes, full gravity resumes. This creates a predictable max height regardless of frame rate.

```gdscript
const JUMP_VELOCITY := -400.0
const GRAVITY := 1200.0
const HALF_GRAV_THRESHOLD := 50.0  ## Below this velocity, half gravity applies
const VAR_JUMP_TIME := 0.2         ## 200ms — Celeste value

var _var_jump_timer: float = 0.0


func _jump() -> void:
	velocity.y = JUMP_VELOCITY
	_var_jump_timer = VAR_JUMP_TIME


func _physics_process(delta: float) -> void:
	var gravity: float = GRAVITY

	if _var_jump_timer > 0.0:
		_var_jump_timer -= delta
		if Input.is_action_pressed("jump") and velocity.y < HALF_GRAV_THRESHOLD:
			gravity *= 0.5
		elif not Input.is_action_pressed("jump"):
			_var_jump_timer = 0.0  # Stop the variable jump window

	velocity.y += gravity * delta
```

### Fall Gravity Multiplier

Celeste, Dead Cells, and most modern platformers apply heavier gravity when falling than when rising. The result: the jump arc is asymmetric — slow rise, fast fall. This feels more responsive because the player spends less time in the air after the peak, getting back to the ground (and back to control) faster.

**Typical ratio**: fall gravity = 1.5-2.5x rise gravity. Celeste's `FastMaxAccel = 300` combined with `FastMaxFall = 240` vs normal `MaxFall = 160` creates the fast-fall mechanic when holding down.

---

## 6. Air Control

### What the Player Feels

**High air control** (Celeste, Ori): you can steer mid-jump almost as well as on the ground. Jumps feel like controlled flight. Precision platforming demands this.

**Low air control** (classic Castlevania): once you jump, you're committed. The arc is mostly fixed. Every jump is a decision with consequences.

### The Air Multiplier

Celeste uses `AirMult = 0.65` — air horizontal acceleration is 65% of ground acceleration. This is the sweet spot for precision platformers: enough control to correct mistakes, not so much that the ground/air distinction disappears.

| Game | Air Multiplier | Feel |
|------|---------------|------|
| Celeste | 0.65x | Controlled, precise |
| Dead Cells | ~0.70x | Slightly more aerial freedom |
| Hollow Knight | ~0.85x | Near-full control (fits combat) |
| Ori | ~0.90x | Almost full control (fluid) |
| Castlevania | ~0.0x | Committed arc |
| Mario | ~0.50x | Moderate steering |

### Air Deceleration

When the player releases all directional input in the air: should the character drift (low air decel) or stop horizontally quickly (high air decel)?

**For precision platformers**: high air decel. The player needs to stop on a dime above small platforms. Celeste's `RunReduce = 400` applies equally in air.

**For combat platformers**: moderate air decel. Some drift makes aerial combos feel fluid. Hollow Knight lets you adjust mid-air but doesn't decelerate aggressively.

---

## 7. Corner Correction / Ledge Assist

### What the Player Feels

They clipped the edge of a platform with their head, but instead of bonking and falling, they slid around the corner. They don't know it happened. The game felt fair.

### Celeste's Corner Correction

Dashing horizontally into a ledge or touching the ceiling within 4 pixels of a corner causes Madeline to be pushed around the corner rather than getting stopped. She can also wall jump from 2 pixels away from a wall — a quarter of a tile at 320x180 resolution.

**Vertical correction** (bonking ceiling corners): if the character's head clips a solid block by 1-4 pixels at the corner, nudge them horizontally around it. No speed loss. No sound effect. Invisible.

**Horizontal correction** (ledge grab assist): if the character passes a ledge and their feet are 1-4 pixels above the edge, pop them up onto the platform. This is the "magnetic ledge" feeling.

### Implementation

The approach is to split movement into two functions — X and Y — which makes collision resolution easier. When a collision is detected, try small offsets perpendicular to the movement direction. If any offset resolves the collision, silently apply it.

```gdscript
const CORNER_CORRECTION_PX := 6  ## Pixels to check for corner correction
const CORNER_CHECK_STEP := 1     ## Check every pixel


## Call after move_and_slide() when a ceiling collision is detected
func _try_vertical_corner_correction() -> bool:
	if not is_on_ceiling():
		return false

	# Try nudging left and right to get around the corner
	for offset in range(1, CORNER_CORRECTION_PX + 1, CORNER_CHECK_STEP):
		for dir in [-1, 1]:
			var test_pos: Vector2 = position + Vector2(dir * offset, 0)
			# Test if the character would NOT collide at this offset
			var space := get_world_2d().direct_space_state
			var params := PhysicsRayQueryParameters2D.create(
				test_pos + Vector2(0, -1),
				test_pos + Vector2(0, -4),
			)
			params.exclude = [get_rid()]
			var result := space.intersect_ray(params)
			if result.is_empty():
				position.x += dir * offset
				return true
	return false


## Ledge assist: if the character barely missed a platform edge
func _try_ledge_assist() -> bool:
	if velocity.y >= 0.0 or is_on_floor():
		return false

	# Check a few pixels below for a floor we barely missed
	for offset in range(1, CORNER_CORRECTION_PX + 1):
		var test_pos: Vector2 = position + Vector2(0, offset)
		# Simplified: test_move downward
		var collision := KinematicCollision2D.new()
		if test_move(Transform2D(0, test_pos - position), collision):
			position.y += offset
			velocity.y = 0.0
			return true
	return false
```

### A Simpler Approach: Expanded Collision

Instead of post-collision correction, shrink the player's collision shape by 2-4 pixels at the top corners. A capsule or rounded rectangle naturally slides around corners. This is less precise but covers 80% of cases with zero code.

**CollisionShape2D tips**:
- Top of character: narrower, rounded (slides around ceilings)
- Bottom of character: flat or only slightly rounded (needs precise floor detection)
- Width: 60-75% of sprite width (visually forgiving)

---

## 8. The Complete Controller

### GDScript — Full-Featured CharacterBody2D

All the systems from this recipe integrated into one controller. Copy-paste baseline with `@export` tunables.

```gdscript
class_name PlatformerController
extends CharacterBody2D

## ========== MOVEMENT ==========
@export_group("Movement")
@export var max_speed := 300.0           ## Maximum horizontal speed
@export var acceleration := 2000.0       ## Ground acceleration (px/s^2)
@export var deceleration := 1200.0       ## Ground deceleration (px/s^2)
@export var turn_multiplier := 1.5       ## Extra accel when changing direction
@export var air_multiplier := 0.65       ## Air control ratio (Celeste = 0.65)

## ========== JUMP ==========
@export_group("Jump")
@export var jump_velocity := -400.0      ## Initial jump velocity (negative = up)
@export var gravity := 1200.0            ## Base gravity (px/s^2)
@export var fall_gravity_mult := 2.0     ## Gravity multiplier when falling
@export var short_jump_gravity_mult := 2.5  ## Gravity multiplier when jump released
@export var max_fall_speed := 600.0      ## Terminal velocity
@export var fast_fall_speed := 900.0     ## Terminal velocity when holding down
@export var var_jump_time := 0.2         ## Variable jump window (Celeste = 0.2)

## ========== FORGIVENESS ==========
@export_group("Forgiveness")
@export var coyote_time := 0.1           ## Coyote time window (Celeste = 0.1)
@export var jump_buffer_time := 0.1      ## Jump buffer window
@export var corner_correction_px := 6    ## Corner correction tolerance

## ========== STATE ==========
var _coyote_timer: float = 0.0
var _jump_buffer_timer: float = 0.0
var _var_jump_timer: float = 0.0
var _was_on_floor: bool = false
var _is_jumping: bool = false


func _physics_process(delta: float) -> void:
	var on_floor: bool = is_on_floor()

	_handle_coyote_time(on_floor)
	_handle_input_buffer()
	_apply_gravity(delta)
	_handle_horizontal_movement(delta)
	_handle_jump(on_floor)

	move_and_slide()
	_was_on_floor = on_floor


## ---------- COYOTE TIME ----------
func _handle_coyote_time(on_floor: bool) -> void:
	# Just walked off edge (was on floor, now isn't, not jumping)
	if _was_on_floor and not on_floor and not _is_jumping:
		_coyote_timer = coyote_time
	elif on_floor:
		_coyote_timer = 0.0  # Reset — we're grounded

	if _coyote_timer > 0.0:
		_coyote_timer -= get_physics_process_delta_time()


## ---------- INPUT BUFFER ----------
func _handle_input_buffer() -> void:
	if Input.is_action_just_pressed("jump"):
		_jump_buffer_timer = jump_buffer_time

	if _jump_buffer_timer > 0.0:
		_jump_buffer_timer -= get_physics_process_delta_time()


## ---------- GRAVITY ----------
func _apply_gravity(delta: float) -> void:
	if is_on_floor():
		return

	var grav: float = gravity

	if velocity.y > 0.0:
		# Falling — heavier gravity
		grav *= fall_gravity_mult
		_is_jumping = false
	elif velocity.y < 0.0 and _is_jumping:
		# Ascending — check variable jump
		if _var_jump_timer > 0.0:
			_var_jump_timer -= delta
			if Input.is_action_pressed("jump"):
				grav *= 0.5  # Half gravity while holding jump
			else:
				_var_jump_timer = 0.0  # Cut the jump short
		else:
			grav *= short_jump_gravity_mult

	velocity.y += grav * delta

	# Terminal velocity
	var max_fall: float = max_fall_speed
	if Input.is_action_pressed("move_down"):
		max_fall = fast_fall_speed
	velocity.y = minf(velocity.y, max_fall)


## ---------- HORIZONTAL MOVEMENT ----------
func _handle_horizontal_movement(delta: float) -> void:
	var input_dir: float = Input.get_axis("move_left", "move_right")

	if input_dir != 0.0:
		var accel: float = acceleration

		# Air control reduction
		if not is_on_floor():
			accel *= air_multiplier

		# Turn boost — higher accel when reversing direction
		if signf(input_dir) != signf(velocity.x) and absf(velocity.x) > max_speed * 0.1:
			accel *= turn_multiplier

		velocity.x = move_toward(velocity.x, input_dir * max_speed, accel * delta)
	else:
		var decel: float = deceleration
		if not is_on_floor():
			decel *= air_multiplier
		velocity.x = move_toward(velocity.x, 0.0, decel * delta)


## ---------- JUMP ----------
func _handle_jump(on_floor: bool) -> void:
	var can_jump: bool = on_floor or _coyote_timer > 0.0
	var wants_jump: bool = Input.is_action_just_pressed("jump") or _jump_buffer_timer > 0.0

	if can_jump and wants_jump:
		_execute_jump()


func _execute_jump() -> void:
	velocity.y = jump_velocity
	_is_jumping = true
	_var_jump_timer = var_jump_time
	_coyote_timer = 0.0        # Consume coyote time
	_jump_buffer_timer = 0.0   # Consume jump buffer
```

### Preset Configurations

Paste these export overrides in the Inspector to match specific game feels:

**Celeste-like** (precision, snappy):
```
max_speed = 300, acceleration = 3000, deceleration = 1500
air_multiplier = 0.65, turn_multiplier = 2.0
jump_velocity = -420, gravity = 1200
fall_gravity_mult = 1.8, short_jump_gravity_mult = 2.5
var_jump_time = 0.2, coyote_time = 0.1, jump_buffer_time = 0.1
```

**Hollow Knight-like** (precise, deliberate):
```
max_speed = 250, acceleration = 10000, deceleration = 10000
air_multiplier = 0.85, turn_multiplier = 1.0
jump_velocity = -450, gravity = 1400
fall_gravity_mult = 2.0, short_jump_gravity_mult = 3.0
var_jump_time = 0.15, coyote_time = 0.083, jump_buffer_time = 0.067
```

**Dead Cells-like** (fast, combat-oriented):
```
max_speed = 350, acceleration = 3500, deceleration = 2000
air_multiplier = 0.70, turn_multiplier = 1.3
jump_velocity = -380, gravity = 1100
fall_gravity_mult = 2.2, short_jump_gravity_mult = 2.8
var_jump_time = 0.12, coyote_time = 0.067, jump_buffer_time = 0.067
```

**Ori-like** (fluid, aerial):
```
max_speed = 380, acceleration = 4000, deceleration = 1800
air_multiplier = 0.90, turn_multiplier = 1.5
jump_velocity = -400, gravity = 1000
fall_gravity_mult = 1.5, short_jump_gravity_mult = 2.0
var_jump_time = 0.18, coyote_time = 0.1, jump_buffer_time = 0.083
```

---

## 9. Advanced Techniques

### Speed Retention on Slopes

On steep slopes, the character should maintain or gain speed going down, and lose speed going up. Without this, slopes feel flat — just visual decoration with no gameplay impact.

```gdscript
## Call in _physics_process after move_and_slide
func _apply_slope_speed(delta: float) -> void:
	if not is_on_floor():
		return
	var floor_normal: Vector2 = get_floor_normal()
	var slope_angle: float = floor_normal.angle_to(Vector2.UP)
	if absf(slope_angle) < 0.1:
		return  # Nearly flat, skip

	# Going downhill: speed boost. Uphill: speed penalty.
	var slope_factor: float = sin(slope_angle)
	var dir_sign: float = signf(velocity.x)
	var slope_dir: float = signf(floor_normal.x)

	if dir_sign == slope_dir:
		# Moving downhill
		velocity.x += slope_factor * 400.0 * delta
	else:
		# Moving uphill
		velocity.x -= slope_factor * 200.0 * delta
```

### Jump Apex Hang Time

At the top of the jump arc (when vertical velocity is near zero), reduce gravity temporarily. This extends the "floaty" moment at the peak. Celeste does this with `HalfGravThreshold = 40` — when `velocity.y` is between 0 and 40 and the player holds jump, gravity is halved.

The feel: the character lingers at the apex, giving the player extra time to line up a landing. It looks like the character is reaching — the peak becomes dramatic rather than mathematical.

```gdscript
const APEX_THRESHOLD := 60.0   ## Velocity range near zero considered "apex"
const APEX_GRAVITY_MULT := 0.4 ## Gravity reduction at apex (lower = floatier)

# In the gravity function:
if absf(velocity.y) < APEX_THRESHOLD and _is_jumping:
	gravity *= APEX_GRAVITY_MULT
```

### Wall Interactions (Slide + Jump)

**Wall slide**: when the character is against a wall and falling, reduce `max_fall_speed` to 20-40% of normal. The character "grips" the wall. Visual: slower fall animation, dust particles at the wall contact.

**Wall jump**: launch the character away from the wall with both horizontal and vertical velocity. Celeste's wall jump gives a horizontal speed boost beyond `MaxRun` (via `JumpHBoost = 40`), making wall jumps feel propulsive.

**The lock-out**: after a wall jump, briefly prevent the player from moving back toward the wall (100-150ms). Without this, the player can mash jump against a single wall to climb infinitely. Celeste uses `WallJumpForceTime` for this.

```gdscript
const WALL_SLIDE_SPEED := 100.0
const WALL_JUMP_VELOCITY := Vector2(250.0, -400.0)
const WALL_JUMP_LOCKOUT := 0.12  ## Seconds before player can move toward wall again

var _wall_jump_dir: float = 0.0
var _wall_jump_lockout_timer: float = 0.0


func _handle_wall_slide() -> void:
	if is_on_wall() and not is_on_floor() and velocity.y > 0.0:
		velocity.y = minf(velocity.y, WALL_SLIDE_SPEED)


func _handle_wall_jump() -> void:
	if not is_on_wall() or is_on_floor():
		return
	if not Input.is_action_just_pressed("jump"):
		return

	var wall_normal: Vector2 = get_wall_normal()
	velocity.x = wall_normal.x * WALL_JUMP_VELOCITY.x
	velocity.y = WALL_JUMP_VELOCITY.y
	_wall_jump_dir = wall_normal.x
	_wall_jump_lockout_timer = WALL_JUMP_LOCKOUT
	_is_jumping = true
	_var_jump_timer = var_jump_time


# In _handle_horizontal_movement, add this guard:
func _is_wall_locked(input_dir: float) -> bool:
	if _wall_jump_lockout_timer <= 0.0:
		return false
	_wall_jump_lockout_timer -= get_physics_process_delta_time()
	# Block input toward the wall we just jumped from
	return signf(input_dir) != signf(_wall_jump_dir)
```

### Speed Boost on Consecutive Jumps

If the player lands and immediately jumps again (within the buffer window), add +10-15% horizontal speed. This rewards rhythmic play and creates a "bunny hop" momentum feel. Celeste grants `JumpHBoost = 40` (additional speed beyond `MaxRun`) — this is why speed-running Celeste involves chaining jumps.

```gdscript
const JUMP_H_BOOST := 40.0  ## Horizontal speed bonus on consecutive jumps
const BOOST_WINDOW := 0.15  ## How recent the landing must be

var _time_since_landing: float = 999.0


func _on_land() -> void:
	_time_since_landing = 0.0


func _execute_jump() -> void:
	velocity.y = jump_velocity
	# Speed boost if landing was recent (chain jump)
	if _time_since_landing < BOOST_WINDOW and absf(velocity.x) > max_speed * 0.5:
		velocity.x += signf(velocity.x) * JUMP_H_BOOST
	_time_since_landing = 999.0
	# ... rest of jump logic
```

---

## Gotchas

1. **`move_and_slide()` applies delta internally** — don't multiply `velocity` by `delta` before calling it. Set `velocity` in absolute px/s, `move_and_slide` handles the rest. Double-applying delta = character moves at pixels-per-second-squared

2. **Coyote time + moving platforms = teleportation** — if the platform moves after the player walks off, the coyote jump launches from the NEW floor position. Either store the coyote jump position (not the floor reference) or accept the quirk

3. **`is_on_floor()` is only valid after `move_and_slide()`** — calling it before the first `move_and_slide` in a frame returns stale data from the previous frame. Structure: update velocity first, `move_and_slide()` second, check collisions third

4. **`velocity.y = jump_velocity` in `_physics_process` runs every physics frame** — if the jump input check isn't gated by `is_action_just_pressed`, the character rockets upward continuously. Always use `just_pressed` for the initial impulse, `is_pressed` only for variable jump hold

5. **Gravity values scale with resolution** — Celeste's `Gravity = 900` is for 320x180 resolution. At 1080p, equivalent gravity would be ~5400. Always tune values relative to your viewport size and character scale, not absolute pixel values

6. **`get_floor_normal()` returns `Vector2.ZERO` when not on floor** — calling `angle_to(Vector2.UP)` on a zero vector gives unpredictable results. Always guard with `is_on_floor()` before using floor normal

7. **`floor_snap_length` too low = bouncing down stairs** — the character becomes airborne for 1 frame at each step edge, triggering coyote time and fall animations. Set `floor_snap_length` to at least your step height (8-16px for typical platformer tiles)

8. **Variable jump with physics interpolation = inconsistent heights** — if `_physics_process` runs at a fixed rate but rendering interpolates between frames, the frame where the player releases jump may be quantized differently. Use `physics_ticks_per_second = 60` (matching common display rate) for consistent feel

9. **Input buffering persists through death** — if the player mashes jump while dying, the buffer carries into the respawn. Clear all buffers on death and on scene transitions: `_jump_buffer_timer = 0.0; _coyote_timer = 0.0`

10. **The 60fps assumption trap** — all frame-count values (coyote = 6 frames, buffer = 5 frames) assume 60fps. Converting to seconds (0.1s, 0.083s) makes the code frame-rate independent. Never use frame counts in code — always time in seconds with `delta`

11. **Hollow Knight has NO input acceleration** — don't add acceleration to a "Hollow Knight-like" profile thinking it'll feel better. The instant stop/start IS the feel. Setting acceleration to 10000+ effectively creates instant response. Some games succeed by removing a mechanic, not adding one

12. **Air deceleration too low = "ice skating in the sky"** — if the character keeps full momentum in air with near-zero decel, jumps feel uncontrollable. Match air decel to at least 50% of ground decel as a minimum baseline
