Try this: take any movement prototype hacked together in an afternoon, one where velocity.x = input.x * speed runs directly inside Update, and walk across a platform. It works. The character goes where the stick points. And yet something feels wrong. It starts like a robot, it stops like it hit a wall, and any jump feels stiff.
That "something wrong" is almost never a bug. It's the absence of acceleration, friction, and curves: the three tools that separate movement that obeys from movement that feels good. This article walks through those three pieces, with the dash and the jump as the two cases where the difference shows up the most.
1. Raw input isn't the feel
An analog stick or a keyboard hands you a value between -1 and 1 (or, on keyboard, a blunt -1, 0, or 1). The temptation is to multiply that value directly by the max speed and assign it to the physics every frame. It's the shortest possible implementation, and it's the reason so many prototypes feel "programmer made": technically correct, sensorially flat.
The problem is that raw input doesn't represent the player's intent over time. When someone releases the stick, they don't want to stop on the next frame; they want to decelerate. When they push it to the max from a standstill, they don't want to be at max speed on the next frame; they want to feel the character build up momentum. The character's actual speed shouldn't be the input, it should chase the input.
This idea is the same one that solves coyote time and jump buffering: the simulation has an exact state, but the player lives in a world of fuzzy intentions. The job of the controller is to translate between the two.
2. Acceleration and friction: two rates, not one
The first real improvement is to stop treating "changing speed" as a single operation and split it into two: acceleration (when there's input) and friction or deceleration (when there isn't, or when the input points the opposite way). They're conceptually distinct and should almost never share the same value.
1public class MovementController : MonoBehaviour
2{
3 [SerializeField] private float maxSpeed = 8f;
4 [SerializeField] private float acceleration = 60f;
5 [SerializeField] private float friction = 80f;
6
7 private float currentSpeed;
8 private Rigidbody2D rb;
9
10 private void Awake() => rb = GetComponent<Rigidbody2D>();
11
12 private void FixedUpdate()
13 {
14 float input = Input.GetAxisRaw("Horizontal");
15 float targetSpeed = input * maxSpeed;
16
17 float rate = Mathf.Abs(targetSpeed) > 0.01f ? acceleration : friction;
18 currentSpeed = Mathf.MoveTowards(currentSpeed, targetSpeed, rate * Time.fixedDeltaTime);
19
20 rb.velocity = new Vector2(currentSpeed, rb.velocity.y);
21 }
22}Mathf.MoveTowards moves the current speed toward the target at a fixed rate per second, and that rate changes depending on whether there's input or not. A friction higher than the acceleration gives a character that stops on a dime (typical of a shooter). A lower friction gives a character that slides (typical of an icy-floor level or a vehicle). Neither is "the correct one": they're design decisions, and that's why they should be two separate fields, not one shared constant.
A common mistake is using the same variable for acceleration and friction "to keep it simple." The result is a character that starts up exactly as slowly as it stops, which is almost never what you want: usually you'd rather stop faster than you start, because the player expects immediate control on releasing the input, not on pressing it.
3. Why a naive lerp depends on framerate
Mathf.MoveTowards works because it advances a fixed amount per second. But it's common to see this other version, which looks equivalent and isn't:
1// Lerp version: looks reasonable, isn't
2currentSpeed = Mathf.Lerp(currentSpeed, targetSpeed, 0.1f);The problem is that 0.1f is a fraction of the remaining distance per frame, not per second. At 30 fps, speed closes 10% of the gap each frame, 30 times a second. At 60 fps, it's also 10%, but 60 times a second. The result is that the same code produces different acceleration depending on the machine it runs on, and a game tuned at 60 fps will feel noticeably slower or faster on a 144 Hz screen.
The frame-independent fix for an exponential lerp uses deltaTime as an exponent, not as a factor:
Where b is the fraction of the distance that survives each second (for example, 0.01 means that after one second only 1% of the original distance is left). Translated to code:
1float ExponentialLerp(float current, float target, float decayPerSecond, float deltaTime)
2{
3 float t = Mathf.Pow(decayPerSecond, deltaTime);
4 return target + (current - target) * t;
5}With this version, decayPerSecond means the same thing at 30, 60, or 144 fps: the approach curve is identical, only the resolution at which it's sampled changes. Mathf.MoveTowards is still the simplest option and more than enough for linear acceleration/friction, but as soon as you need a smoothing curve (for a camera, say, or for the dash in the next section), this formula is the better choice.
4. The dash: a velocity curve, not a teleport
The dash is the case where working with curves instead of loose numbers shows the most. The naive implementation applies a fixed velocity for N frames and then cuts it off:
1// Naive version: constant speed, abrupt cutoff
2if (isDashing)
3{
4 rb.velocity = dashDirection * dashSpeed;
5 dashTimer -= Time.deltaTime;
6 if (dashTimer <= 0f) isDashing = false;
7}It feels mechanical because velocity is a step function: zero, then dashSpeed all at once, then zero all at once again. A dash curve fixes this by describing how velocity evolves over the duration of the dash, usually with a strong initial peak and a decaying tail, instead of a flat value:
1[SerializeField] private AnimationCurve dashCurve = AnimationCurve.EaseInOut(0, 1, 1, 0);
2[SerializeField] private float dashSpeed = 20f;
3[SerializeField] private float dashDuration = 0.2f;
4
5private float dashElapsed;
6
7private void UpdateDash()
8{
9 float normalizedTime = dashElapsed / dashDuration;
10 float curveValue = dashCurve.Evaluate(normalizedTime);
11
12 rb.velocity = dashDirection * dashSpeed * curveValue;
13
14 dashElapsed += Time.deltaTime;
15 if (dashElapsed >= dashDuration)
16 EndDash();
17}The AnimationCurve exposed in the inspector is the key piece: it lets a designer draw the shape of the dash by hand (an explosive start with a soft tail, or a progressive ramp, or even a small bounce at the end) without touching code. That's the real value of working with curves instead of constants: it moves the fine tuning of the feel out of the code and puts it in the hands of whoever is playing the game over and over.
A common trick in action games is to have the dash ignore the normal friction while active (isDashing disables the MoveTowards from section 2), and to keep the dash's final speed as the new base speed when it ends, instead of cutting it to zero. That way the dash feels like momentum gained, not a separate state that toggles on and off.
5. The jump: variable gravity to control weight
The jump has the same problem as the dash: a parabola generated with a single gravity constant feels uniform, and "uniform" is rarely "good." Platformers that feel nimble (Celeste, Hollow Knight, Mario) almost always use variable gravity: a different rate while the character rises and another while it falls.
1[SerializeField] private float baseGravity = 20f;
2[SerializeField] private float risingGravityMultiplier = 1f;
3[SerializeField] private float fallingGravityMultiplier = 1.8f;
4[SerializeField] private float lowJumpGravityMultiplier = 2.5f;
5
6private void ApplyGravity()
7{
8 float multiplier = risingGravityMultiplier;
9
10 if (rb.velocity.y < 0f)
11 multiplier = fallingGravityMultiplier;
12 else if (rb.velocity.y > 0f && !Input.GetButton("Jump"))
13 multiplier = lowJumpGravityMultiplier; // short hop: player released the button early
14
15 rb.velocity += Vector2.up * baseGravity * multiplier * Time.deltaTime;
16}With fallingGravityMultiplier above 1, the fall is faster than the rise, which gives a sense of weight and immediate response on landing, instead of a symmetric parabola that feels floaty. With lowJumpGravityMultiplier, releasing the jump button early cuts the rise more sharply, giving fine control over jump height without needing extra buttons or states: it's the same trick Mario and Celeste use to tell a short hop apart from a held jump.
This variable jump-height control combines naturally with the coyote time and jump buffering covered in the previous article: one decides when you're allowed to jump, this one decides how the jump feels once you're in the air. They're independent layers that stack without stepping on each other.
6. Ground friction, partial control in the air
A nuance that's often overlooked: the friction from section 2 shouldn't apply the same way on the ground as it does in the air. On the ground, the player expects near-immediate response on releasing input. In the air, real physics (and most games) deliberately reduces that response, because a character in the air has already committed to a trajectory, and full correction mid-jump breaks the sense of being subject to gravity.
1[SerializeField] private float airControlFactor = 0.5f; // 0 = no air control, 1 = same as ground
2
3private void FixedUpdate()
4{
5 float input = Input.GetAxisRaw("Horizontal");
6 float targetSpeed = input * maxSpeed;
7
8 float rate = Mathf.Abs(targetSpeed) > 0.01f ? acceleration : friction;
9 if (!isGrounded)
10 rate *= airControlFactor;
11
12 currentSpeed = Mathf.MoveTowards(currentSpeed, targetSpeed, rate * Time.fixedDeltaTime);
13 rb.velocity = new Vector2(currentSpeed, rb.velocity.y);
14}A low airControlFactor (0.2 to 0.4) gives committed jumps, where once you're in the air the trajectory is nearly locked in: it fits harder or more realistic platformers. A high value (0.7 to 1) gives full air control, typical of more arcade-style games where correcting mid-fall is part of the fun. Neither is objectively better: it's a decision that changes the genre the player perceives.
7. Expose the curves and tune with data, not intuition
Every number in this article (acceleration, friction, gravity multipliers, dash curve, air control factor) should live as serialized fields or AnimationCurves in the inspector, never as hardcoded constants in the code. The reason isn't just convenience: fine-tuning game feel isn't done by reading code, it's done by playing repeatedly and dragging a slider until the controls feel comfortable and "right."
A game's movement isn't designed once, it's distilled. You play, you nudge a number, you play again. Nobody nails a dash curve on the first try.
Just like was documented for coyote time and jump buffering, the final step is always the same: dedicate entire sessions purely to moving these values with testers who don't know the code, because the developer who's been living with the prototype for weeks can no longer feel their own movement with fresh eyes. A friction that feels "instant" to the programmer often turns out slow for someone playing the game for the first time.
8. Conclusion
Acceleration, friction, and curves aren't exotic tricks: they're the difference between assigning input directly to physics and modeling how the character arrives at the speed the player is asking for. The dash and the jump are just the two places where that difference becomes impossible to ignore, because they're the most extreme and most visible moves in any character's moveset.
The lesson that repeats in every section is the same: separate the rates that should be independent (acceleration from friction, rise from fall, ground from air), pull them out of the code into curves and serialized values, and tune them by playing rather than by calculating. The result, when it works, is invisible: nobody is going to praise your dash's decay curve. They're just going to say it feels good, without knowing why.



