Jump Timing: Input Buffering and Coyote Time
Perhaps the most critical aspect of jump mechanics — even more than how the jump feels — is ensuring players aren’t unfairly penalized for slight timing inaccuracies:
- If the player presses the jump button a moment before landing, the jump should still trigger.
- If the player presses the jump button a fraction of a second after walking off a ledge, the character should still jump.
These are common game design challenges, typically solved with input buffering and coyote jumping, respectively.
While these techniques are not strictly exclusive to kinematic controllers, they are essential for responsive character movement and well worth discussing.
Input buffering
Input buffering is a technique that caches an input so it can be processed shortly after being pressed. In our case, it lets the character jump even if the player presses the jump button a moment before touching the ground.
To handle this, I created a ButtonBuffer class that maintains the button’s pressed state for a set duration after the physical press occurs.
class ButtonBuffer
{
// Content of the class
}
To initialize the class, we need to specify the buffer duration. To keep control over how time is tracked, I also use dependency injection to pass a getter for the current time, with a default fallback if none is provided.
using System;
using UnityEngine;
// Inside the class
readonly Func<double> getTime;
public ButtonBuffer(Func<double> getTime = null) =>
this.getTime = getTime ?? (() => Time.unscaledTime);
As usual, I’m using double, since the performance impact is negligible and I don’t have to worry about precision.
The class API revolves around two main needs:
- A way to update the button’s state.
- A way to consume the buffered input. Once consumed, later calls won’t register the button as pressed until it is pressed again. Consuming the state should return a handle to check two things: whether the button was considered pressed at the moment of consumption, and whether that same press is still being held down.
To understand why we need that second check, consider a variable jump height mechanic: the player presses the jump button, initiating the jump. If they keep the button held down, we want to continue applying upward force for a short duration. However, simply checking if the button is currently down isn’t enough — if the player releases the button and presses it again, that shouldn’t be treated as a continuation of the initial hold.
Let’s define the API.
// Inside the class
public void ChangeState(bool buttonState);
public State Consume(double timeWindow);
public readonly struct State
{
public bool WasPressed { get; }
public bool IsStillHeld { get; }
}
State is a struct to avoid heap allocations whenever input is consumed.
How do we track whether a hold is still part of the original press? While there are several ways to implement this, I opted for a simple approach: incrementing a counter every time the button’s state changes. This counter acts as a unique ID for the current hold. When the button is released, the ID changes, ensuring that even if the button is pressed again, it is recognized as a new press rather than a continuation of the original one.
The implementation of ChangeState is straightforward.
// Inside the class
double? pressTime;
bool isHeld;
long lastHoldChangeId;
public void ChangeState(bool buttonState)
{
if (isHeld == buttonState)
return;
lastHoldChangeId++;
isHeld = buttonState;
if (isHeld)
pressTime = getTime();
}
Notice that we also update pressTime whenever the button is pressed. To see why, let’s look at the Consume implementation:
// Inside the class
public State Consume(double timeWindow)
{
bool wasPressed = getTime() - pressTime <= timeWindow;
pressTime = null;
return new State(this, wasPressed, lastHoldChangeId);
}
In other words, the press is considered valid as long as it occurred within the buffer window.
The State struct itself is equally straightforward.
// Inside the class
public readonly struct State {
readonly ButtonBuffer buffer;
readonly long eventId;
internal State(ButtonBuffer buffer, bool wasPressed, long eventId)
{
WasPressed = wasPressed;
this.buffer = buffer;
this.eventId = eventId;
}
public bool WasPressed { get; }
public bool IsStillHeld => WasPressed && buffer != null && buffer.lastHoldChangeId == eventId;
}
The key part is the implementation of IsStillHeld, which checks three conditions:
- The button was pressed.
- The buffer reference is not null.
- The buffer’s current change ID matches the event ID captured during consumption.
The null check handles default(State) instances. While not strictly necessary — since WasPressed is already false on a default struct — it serves as a safeguard against future changes.
The final check invalidates IsStillHeld as soon as the button state changes: releasing the button increments the ID, immediately turning IsStillHeld to false.
Here is the complete implementation of the class.
using System;
using UnityEngine;
class ButtonBuffer
{
readonly Func<double> getTime;
double? pressTime;
bool isHeld;
long lastHoldChangeId;
public ButtonBuffer(Func<double> getTime = null) =>
this.getTime = getTime ?? (() => Time.unscaledTime);
public void ChangeState(bool buttonState)
{
if (isHeld == buttonState)
return;
lastHoldChangeId++;
isHeld = buttonState;
if (isHeld)
pressTime = getTime();
}
public State Consume(double timeWindow)
{
bool wasPressed = getTime() - pressTime <= timeWindow;
pressTime = null;
return new State(this, wasPressed, lastHoldChangeId);
}
public readonly struct State
{
readonly ButtonBuffer buffer;
readonly long eventId;
internal State(ButtonBuffer buffer, bool wasPressed, long eventId) {
WasPressed = wasPressed;
this.buffer = buffer;
this.eventId = eventId;
}
public bool WasPressed { get; }
public bool IsStillHeld => WasPressed && buffer != null && buffer.lastHoldChangeId == eventId;
}
}
Timed flags and coyote jumping
If you’re unfamiliar with coyote jumping, it allows the character to jump for a brief window after leaving the ground. To see why this is useful, imagine running off a platform and pressing the jump button a fraction of a second too late. If the character has already stepped off the ledge, failing to jump would unfairly penalize the player for an imperceptible timing error — even though their input was practically at the edge. Giving the player a short grace period ensures the controls feel responsive and fair rather than frustrating.
This presents a very similar problem to input buffering: we want to know whether a specific state was active within the last fraction of a second. Unlike input buffering, however, we don’t need to invalidate or consume the state — we only need to check if it held a certain value during that time window. In cases like coyote jumping, all we need to know is whether the character was grounded recently, which boils down to tracking a single bool.
To achieve this, we only need to record the last time the boolean changed value.
using System;
using UnityEngine;
class Flag
{
readonly Func<double> getTime;
bool currentValue;
public double? LastChangeTime { get; private set; }
public bool CurrentValue // Temporary implementation
{
get => currentValue;
set => currentValue;
}
public Flag(bool value = false, Func<double> getTime = null)
{
this.getTime = getTime ?? (() => Time.unscaledTime);
currentValue = value;
}
// Other methods
}
I named the class Flag since it is versatile enough to be used for many different purposes.
It exposes both the current value and the timestamp of its last change. LastChangeTime is nullable and will be null in two scenarios: if the value has never changed, or if ForgetHistory was called.
// Inside the class
public void ForgetHistory() =>
LastChangeTime = null;
Why would we want to reset the flag’s history? Consider using this class to track whether a character is grounded. When the character jumps, they leave the ground. If the player rapidly releases and presses the jump button again while still within the coyote time window, the system would still see that the character was grounded a moment ago and grant a coyote jump — resulting in an unintended double jump. Calling ForgetHistory whenever a jump is initiated clears this timestamp and prevents the issue.
Now let’s look at how values are updated and queried against a time window. First, updating the value.
// Inside the class
public bool CurrentValue
{
get => currentValue;
set {
if (value == CurrentValue)
return;
LastChangeTime = getTime();
currentValue = value;
}
}
The logic is straightforward: if the value hasn’t changed, it simply returns; otherwise, it updates the value and records the timestamp.
Next, to check whether the flag held a specific value within a given time window, we can use the following method:
// Inside the class
public bool HadValueInTimeWindow(bool value, double timeWindow) =>
CurrentValue == value || LastChangeTime + timeWindow >= getTime();
The method returns true in two cases:
- If the current value already matches
value, the check succeeds immediately. - If the values differ, the flag must have held
valueright up untilLastChangeTime, so we only need to check whetherLastChangeTimefalls within the specified time window.
To complete the class, there is just one last method to add.
public static implicit operator bool(Flag flag) =>
flag.CurrentValue;
Since reading a flag’s state is far more common than updating it, this implicit conversion lets us treat Flag directly as a bool without having to access CurrentValue explicitly, keeping the code clean and readable.
Here is the complete implementation of the class.
using System;
using UnityEngine;
class Flag
{
readonly Func<double> getTime;
bool currentValue;
public double? LastChangeTime { get; private set; }
public bool CurrentValue
{
get => currentValue;
set
{
if (value == CurrentValue)
return;
LastChangeTime = getTime();
currentValue = value;
}
}
public Flag(bool value = false, Func<double> getTime = null)
{
this.getTime = getTime ?? (() => Time.unscaledTime);
currentValue = value;
}
public bool HadValueInTimeWindow(bool value, double timeWindow) =>
CurrentValue == value || LastChangeTime + timeWindow >= getTime();
public static implicit operator bool(Flag flag) =>
flag.CurrentValue;
public void ForgetHistory() =>
LastChangeTime = null;
}
Conclusion
With both ButtonBuffer and Flag in place, handling responsive jump timing becomes straightforward. Here is a simplified example of how the two utilities work together in a character controller (keep in mind this is for illustration and does not represent the full implementation):
// Setup
ButtonBuffer jumpBuffer = new(() => Simulator.SimulationTime);
Flag isGrounded = new(true, () => Simulator.SimulationTime);
// In Update: capture raw player input
jumpBuffer.ChangeState(InputAction.Player.Jump.IsPressed());
// Jump if possible and required
ButtonBuffer.State jump = jumpBuffer.Consume(0.1);
if (jump.WasPressed && isGrounded.HadValueInTimeWindow(true, timeWindow: 0.15))
{
isGrounded.CurrentValue = false;
isGrounded.ForgetHistory();
ApplyJumpForce();
}
// Continue applying upward force if still holding the button
if (jump.IsStillHeld && canApplyExtraJumpForce)
ApplyExtraJumpForce();
This design offers several key advantages:
- Zero allocations after initialization: Neither class allocates heap memory during queries or state updates. Returning a
structfromConsumeavoids garbage collection pressure in performance-critical loops. - Time source injection: Injecting a
getTimedelegate lets these utilities synchronize seamlessly with custom time managers — such as theSimulatorfrom the previous article — while making unit testing straightforward. - Robust edge-case handling: Tracking state transitions via
lastHoldChangeIdprevents stale hold inputs from leaking into later jumps, whileForgetHistoryeliminates accidental double jumps during coyote time. - Reusability: Although introduced here for jumping,
ButtonBuffercan buffer any discrete action (such as dashes, attacks, or parries), andFlagcan track any transient state that benefits from a grace period (such as wall contact or combo chains).
With jump timing covered, upcoming articles will explore other mechanics, such as tuning physical jump trajectories.