One of the most common things said in talks, videos, or game design blogs is that the platformers that feel "fair" aren't. They cheat constantly, and they do it in the player's favor.
When someone says Celeste or Super Mario Bros. have "perfect" controls, what they're perceiving isn't precision. It's forgiveness. The game is interpreting their intent and filling the gaps between what they wanted to do and what they physically did with the controller. Those gaps are milliseconds long, but they decide whether a session feels crisp or unfair.
That's where the two concepts this article covers come in: coyote time and jump buffering.
1. The game's truth is not the player's truth
A platformer maintains a precise internal state. On frame 1402 the character is touching the ground; on frame 1403 it isn't. For the simulation, that change is instantaneous and binary.
The player doesn't live in that world. Their input travels through their nervous system, through the controller, through the USB bus, and through the engine's event loop before becoming an action. Between deciding to jump and the engine registering the press, several frames can pass. If the simulation requires the button to be pressed exactly within the window when the character is touching the ground, the player will miss jumps that felt correct to them.
At 60 fps, one frame lasts about 16.6 ms. Studies on visual reaction time place the human response at around 200-250 ms. That's between 12 and 15 frames of error margin just for reaction time, not counting the physical input travel. Asking for single-frame precision is asking for something the human body cannot deliver consistently.
The design consequence is severe: if your game only reflects the simulation's truth, the player will perceive failures as the game's fault, not theirs. And a player who feels treated unfairly stops playing. The game feel designer's job is to reconcile the two truths.
2. Coyote time: jumping after the edge
Coyote time (a reference to Wile E. Coyote from the Road Runner cartoons, who keeps running for a moment over thin air) is the grace window during which the character can still jump even after leaving the platform.
The case it solves is classic: the player runs toward the edge, wants to jump right at the end, presses the button one or two frames late, and the character is already falling. Without coyote time the jump is ignored and the player falls feeling like "the game didn't respond." With coyote time, during a small window after leaving the ground, the jump is still valid.
The implementation is a timer that fills while the player touches the ground and drains in the air:
1public class CoyoteJump : MonoBehaviour
2{
3 [SerializeField] private float coyoteTime = 0.1f; // ~6 frames at 60fps
4 [SerializeField] private float jumpForce = 12f;
5
6 private float coyoteCounter;
7 private bool isGrounded;
8 private Rigidbody2D rb;
9
10 private void Awake() => rb = GetComponent<Rigidbody2D>();
11
12 private void Update()
13 {
14 // While grounded, the window resets to its max.
15 // When leaving the ground, it drains frame by frame.
16 if (isGrounded)
17 coyoteCounter = coyoteTime;
18 else
19 coyoteCounter -= Time.deltaTime;
20
21 if (Input.GetButtonDown("Jump") && coyoteCounter > 0f)
22 {
23 rb.velocity = new Vector2(rb.velocity.x, jumpForce);
24 coyoteCounter = 0f; // prevent double jump
25 }
26 }
27}The key is the line that sets coyoteCounter to zero after jumping. Without it, a player could leave the ground, jump within the window, and jump again in the air because the counter is still positive. Consuming the window when it's used is what keeps the trick honest.
A value between 0.08 and 0.12 seconds (about 5-7 frames) is the typical range. Below 0.05 the player doesn't notice it and it adds nothing. Above 0.15 it starts to feel like the character "floats," and players who have mastered the game will sense something is off.
3. Input buffering: jumping before landing
Coyote time forgives late jumps. Input buffering (specifically jump buffering) forgives early jumps.
The scenario is the inverse: the player is falling toward the ground and wants to chain another jump right after landing. Since they anticipate the landing, they press the button one or two frames before touching the ground. Without buffering, at the moment of the press the character is still in the air, the jump is discarded, and nothing happens on landing. The player feels the game "swallowed" their input.
The solution is not to discard the press but to remember it during a small window. If the character touches the ground within that window, the stored jump executes:
1public class JumpBuffer : MonoBehaviour
2{
3 [SerializeField] private float bufferTime = 0.1f; // ~6 frames at 60fps
4 [SerializeField] private float jumpForce = 12f;
5
6 private float bufferCounter;
7 private bool isGrounded;
8 private Rigidbody2D rb;
9
10 private void Awake() => rb = GetComponent<Rigidbody2D>();
11
12 private void Update()
13 {
14 // On press, stores the intent in the window.
15 // If unused, drains frame by frame.
16 if (Input.GetButtonDown("Jump"))
17 bufferCounter = bufferTime;
18 else
19 bufferCounter -= Time.deltaTime;
20
21 // On landing, if a jump is pending in the window, execute it.
22 if (bufferCounter > 0f && isGrounded)
23 {
24 rb.velocity = new Vector2(rb.velocity.x, jumpForce);
25 bufferCounter = 0f;
26 }
27 }
28}The structure is almost identical to coyote time, but with the logic inverted. Coyote time remembers that there was ground recently. The buffer remembers that there was a press recently. They are two short memories pointing in opposite directions along the timeline.
4. Combining both without interference
In a real controller, you'll need both at once, and that's where the first subtle problem appears: if they share the same variable or the same if for both, they can interfere. The clean approach is to keep both counters separate and check the jump condition once, against either window.
1private void Update()
2{
3 // Coyote: remembers there was ground recently
4 if (isGrounded)
5 coyoteCounter = coyoteTime;
6 else
7 coyoteCounter -= Time.deltaTime;
8
9 // Buffer: remembers there was a press recently
10 if (Input.GetButtonDown("Jump"))
11 bufferCounter = bufferTime;
12 else
13 bufferCounter -= Time.deltaTime;
14
15 // Jump if there's a pending intent AND still "counts" as grounded
16 if (bufferCounter > 0f && coyoteCounter > 0f)
17 {
18 rb.velocity = new Vector2(rb.velocity.x, jumpForce);
19 bufferCounter = 0f; // consume the intent
20 coyoteCounter = 0f; // and the jump permission
21 }
22}With this combination you cover all four cases: on-time jump, slightly late jump (coyote), slightly early jump (buffer), and most satisfying of all, an early jump right at the edge of a platform (both tricks collaborating). The player perceives a single coherent behavior: "when I want to jump, I jump."
A common mistake is resetting coyoteCounter on the landing event instead of while isGrounded is true. If it's reset on landing, on the first frame on the ground the counter is still zero and the buffer finds no permission to fire, so the buffered jump is lost in exactly the case it was meant to cover. Therefore, reload the coyote counter while grounded, not on the transition.
5. Beyond the jump: attack buffering and combos
Jump buffering is the famous case, but the general idea (remembering an input for a window and executing it when the system is ready) applies to any action with recovery times.
Action games and fighting games have been doing this for decades. When you chain a combo in a character action game like Devil May Cry or in a soulslike, the game accepts the next hit's press before the current animation finishes and queues it. Without that buffer, the timing would need to be frame-perfect at the moment the animation opens the next attack window, which is exactly what makes a game feel rigid.
An input queue generalizes the single counter to multiple actions, also storing when each was pressed so they can be discarded if no longer needed:
1public class InputBufferQueue
2{
3 private struct BufferedInput
4 {
5 public string action;
6 public float timeStamp;
7 }
8
9 private readonly List<BufferedInput> buffer = new();
10 private readonly float window;
11
12 public InputBufferQueue(float window) => this.window = window;
13
14 public void Register(string action)
15 {
16 buffer.Add(new BufferedInput { action = action, timeStamp = Time.time });
17 }
18
19 // Returns true if the action was pressed within the window and consumes it
20 public bool TryConsume(string action)
21 {
22 buffer.RemoveAll(b => Time.time - b.timeStamp > window);
23 int idx = buffer.FindIndex(b => b.action == action);
24 if (idx < 0) return false;
25 buffer.RemoveAt(idx);
26 return true;
27 }
28}The combat system calls TryConsume("Attack") at the moment the animation opens the cancel window. If the player anticipated it, the action is already waiting in the queue and the combo flows. The same structure works for dodges, blocks, or any action that depends on a state not yet available on the frame of the press.
6. Tuning the windows: how many frames to forgive
There's no magic number, but there are sensible ranges. The right unit to think in isn't seconds, it's frames, because the player's perception is tied to the refresh rate.
- Coyote time: 4-7 frames (0.07-0.12 s). Enough to forgive a late reaction without making the character feel like it "floats."
- Jump buffer: 4-8 frames (0.07-0.13 s). Can be slightly more generous than coyote time, since anticipating is more common than reacting late.
- Combat buffer: 6-12 frames. Combos tolerate larger windows because the player chains deliberately, not reactively.
A good practice is exposing these values as serialized fields in the inspector and dedicating a full session just to moving them with a couple of real testers. The numbers that feel right to the developer, who knows the game by heart, are almost always too tight for someone playing for the first time. Game feel is tuned with other people's hands, not spreadsheets.
There's an fps detail many people overlook: if you define the window in seconds, its duration in frames changes with the refresh rate. A 0.1 s window is 6 frames at 60 fps but 12 at 120 fps. If your game runs at a variable frame rate and you notice the feel changing between machines, consider counting the window in fixed frames or anchoring it to fixedDeltaTime.
Good controls don't obey the player; they predict what the player meant to do.
7. Common mistakes and how to debug them
These systems fail silently: nothing breaks, the game just feels off and it's hard to tell why. Here are the usual suspects.
-
The ghost double-jump appears when you forget to consume the window after jumping. The player jumps within the coyote time and, since the counter is still positive one more frame, fires a second jump in the air. Fix: set the counter to zero in the same frame the jump executes.
-
The "eaten" input on landing is usually an update order problem: if the buffer is checked before
isGroundedis recalculated in the same frame, the buffered jump is evaluated against a stale ground state. To avoid this, update ground detection before resolving the jump.
Be careful about mixing Update and FixedUpdate. Reading input with Input.GetButtonDown must happen in Update (otherwise it can miss presses between physics frames), but applying forces to the Rigidbody goes in FixedUpdate. If you read and apply in the same FixedUpdate, you'll drop inputs intermittently and produce a bug that's impossible to reproduce by hand. The safe pattern is: read and buffer in Update, consume and apply in FixedUpdate.
To debug all of this, the most useful approach is to visualize the counters. Draw the value of coyoteCounter and bufferCounter on screen each frame, or paint a colored gizmo when each window is active. Once you see both timers moving in real time, the timing bugs that seemed like black magic become obvious.
8. Conclusion
Coyote time and jump buffering are not patches or shortcuts. They are an explicit acknowledgment that the player and the simulation live in different time frames, and that the job of the controls is to translate between them.
What's interesting is that, when properly tuned, they are completely invisible. Nobody praises Celeste's coyote time because nobody perceives it as a system; they only perceive that the game "feels incredible." And that invisibility is exactly the goal. The best game feel is what the player attributes to their own skill, never suspecting that the game was cheating in their favor all along.


