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:

  1. Buffer phase - capture input events in _unhandled_input (or similar function, as per your design) and stamp them with a timestamp.
  2. 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, null is 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.