Simple Input Buffer in Godot 4
If you've played a platformer with tight controls, you've probably benefited from an input buffer without realizing it. Press jump a few frames before you land, and the jump still registers cleanly. That small grace is the input buffer at work.
This post walks through a minimal, reusable input buffer for Godot 4 (specifically, 4.6.2), implemented as an autoload singleton.
What Problem Does It Solve?
Games run on a fixed physics tick. Player inputs arrive from the OS between those ticks. If a player presses "jump" 50 ms before the character touches the ground, a naive implementation drops that input entirely - the action check fires while still airborne, finds nothing, and the moment passes.
An input buffer records the input event and gives it a short time-to-live (TTL). During the next physics tick the game asks: "was jump pressed recently?" If the event is still within its TTL window, it counts.
The key insight is the two-phase design:
- Buffer phase - capture input events in
_unhandled_input(or similar function, as per your design) and stamp them with a timestamp. - Consume phase - during physics processing, ask the buffer whether a relevant event is still alive, and invalidate it immediately on retrieval so it can only fire once.
The Data Model
Each buffered entry is a small inner class that pairs the original InputEvent with a timestamp and TTL:
class BufferedInputEvent:
var event: InputEvent
var ts: int # Time.get_ticks_msec() when buffered
var ttl: int # max age in milliseconds
func _init(_event: InputEvent, _ts: int, _ttl: int):
event = _event
ts = _ts
ttl = _ttl
The buffer itself is just a Dictionary[String, BufferedInputEvent] keyed by action name (I use action names I assign in Project → Project Settings → Input Map, but naming actions is up to you and per your design). One slot per action - if the player presses "jump" twice before it's consumed, the second press simply overwrites the first, keeping the freshest intent.
Buffering an Event
func buffer_event(action_name: String, event: InputEvent, ttl: int):
var now := Time.get_ticks_msec()
_buffer[action_name] = BufferedInputEvent.new(event, now, ttl)
- simply stamp the current time and store. Again, overwriting is intentional - you always want the most recent press.
Consuming an Event
func consume_event(action_name: String) -> Variant: # InputEvent or null
if !_buffer.has(action_name):
return null
var buffered_event: BufferedInputEvent = _buffer[action_name]
var return_event: Variant = null
var now := Time.get_ticks_msec()
if now - buffered_event.ts <= buffered_event.ttl:
return_event = buffered_event.event
_invalidate_event(action_name) # always consume, even if expired
return return_event
Two things worth noting:
-
Expiry check:
now - ts <= ttl. If the event is too old,nullis returned. The window is configurable per action. -
Always invalidate: whether the event was fresh or stale, it's erased from the buffer. This prevents a single press from triggering the action on multiple consecutive ticks.
Setting It Up as an Autoload
In Project → Project Settings → Autoload, add input_buffer.gd with the name InputBuffer. This makes it a global singleton accessible from any script.
Usage: Buffering a Jump
In the player script, capture jump presses in _unhandled_key_input (or similar function as per your design) and hand them off to the buffer:
const INPUT_BUFFER_JUMP_ACTION_NAME = "jump" # action name as I have it set up in Input Map
const INPUT_BUFFER_JUMP_EVENT_TTL = 80 # ms
func _unhandled_key_input(event: InputEvent):
if event is not InputEventKey:
return
if event.is_action_pressed("jump"):
InputBuffer.buffer_event(INPUT_BUFFER_JUMP_ACTION_NAME, event, INPUT_BUFFER_JUMP_EVENT_TTL)
Tip: 80 ms is roughly 4–5 frames at 60 fps - generous enough to feel forgiving, tight enough not to feel sloppy.
Usage: Consuming in Physics Processing
The consume step belongs in _physics_process of a "grounded" state - the one that handles "jump" actions (or a state machine physics callback), not in input handling:
func _on_grounded_state_physics_processing(_delta: float) -> void:
var jump_event: Variant = InputBuffer.consume_event(INPUT_BUFFER_JUMP_ACTION_NAME)
if jump_event != null:
state_chart.send_event("jump")
return true
# ... handle movement, etc.
When the "grounded" state becomes active (player just landed), the very next physics tick checks the buffer. If a jump was pressed within the TTL window - even before the landing completed - the jump fires immediately.