How to Handle Input in Bevy

Handle input in Bevy by querying the ButtonInput resource for key or mouse events in your update systems.

The character stands still while you mash the spacebar

You've got a sprite on screen. You press space. Nothing happens. You check the console. No errors. The game loop ticks, the frame counter climbs, but your input vanishes. This is the classic "my game ignores me" moment. Bevy doesn't ignore input. It stores input state in a resource that updates at a specific point in the schedule. If your system runs before that update, or queries the wrong resource, you're reading a snapshot from the past. The fix isn't magic. It's querying the right resource at the right time.

Input is a shared clipboard

Bevy treats input like a shared clipboard. The engine watches the keyboard, mouse, and gamepad. Every frame, it copies the current state of every key and button onto a resource called ButtonInput. Your systems don't talk to the hardware. They read the clipboard.

This design decouples input handling from the rest of your game. You don't register callbacks. You don't chain event listeners. You query a resource, you check the bits, you act. The clipboard gets wiped and refilled every frame, so you only see what happened since the last check. This fits the ECS model perfectly. Input is just data. Systems process data.

The resource holds two kinds of information. It tracks which keys are currently held down. It also tracks which keys were just pressed this frame. The distinction matters. Holding a key down produces a stream of "pressed" states. Pressing a key produces a single "just pressed" spike. Confusing the two causes the most common input bugs.

Minimal example

The DefaultPlugins set includes the InputPlugin, which creates the ButtonInput<KeyCode> and ButtonInput<MouseButton> resources. You query these resources in your systems to read input state.

use bevy::prelude::*;

fn main() {
    // DefaultPlugins includes InputPlugin, which sets up ButtonInput resources.
    App::new()
        .add_plugins(DefaultPlugins)
        // Update runs every frame after input is processed.
        .add_systems(Update, check_spacebar)
        .run();
}

/// Prints a message when the spacebar is pressed.
fn check_spacebar(keyboard: Res<ButtonInput<KeyCode>>) {
    // Query the keyboard resource to read the current input state.
    // just_pressed returns true only on the exact frame the key transitions to down.
    if keyboard.just_pressed(KeyCode::Space) {
        println!("Jump!");
    }
}

The system queries Res<ButtonInput<KeyCode>>. Bevy injects the resource automatically. The just_pressed method checks if the spacebar went from up to down this frame. If you hold the key, just_pressed returns false after the first frame. This prevents the jump action from firing repeatedly while you hold the key.

Trust just_pressed for single actions. Trust pressed for continuous actions. Mixing them up breaks your controls.

How the schedule works

Bevy runs systems in a schedule. The schedule determines the order of execution. Input processing happens in the InputSystem schedule, which defaults to running before the Update schedule. By the time your Update systems run, the ButtonInput resource already contains the state for the current frame.

This ordering is critical. If you add your input system to a schedule that runs before InputSystem, you read the state from the previous frame. The character moves one frame late. If you add your system to Update, you're safe. The input resource is fresh.

You can inspect the schedule with bevy::diagnostic tools or by adding log statements. If your input feels laggy, check the schedule order. The compiler won't catch this. The game just behaves wrong.

Put input systems in Update. The schedule is your friend.

Realistic movement example

Movement requires checking multiple keys and combining them into a direction vector. You also need to handle diagonal movement correctly. Pressing W and D at the same time should move the player diagonally, but not faster than moving straight.

use bevy::prelude::*;

#[derive(Component)]
struct Player {
    velocity: Vec2,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_player)
        // Movement runs every frame to keep the player responsive.
        .add_systems(Update, handle_movement)
        .run();
}

fn spawn_player(mut commands: Commands) {
    commands.spawn(Player { velocity: Vec2::ZERO });
}

/// Calculates movement direction from WASD keys.
fn handle_movement(
    // Access the shared keyboard state.
    keyboard: Res<ButtonInput<KeyCode>>,
    // Mutate player components to update velocity.
    mut players: Query<&mut Player>,
) {
    for mut player in &mut players {
        let mut direction = Vec2::ZERO;

        // `pressed` returns true if the key is held down.
        // This allows continuous movement while the key is held.
        if keyboard.pressed(KeyCode::KeyW) {
            direction.y += 1.0;
        }
        if keyboard.pressed(KeyCode::KeyS) {
            direction.y -= 1.0;
        }
        if keyboard.pressed(KeyCode::KeyA) {
            direction.x -= 1.0;
        }
        if keyboard.pressed(KeyCode::KeyD) {
            direction.x += 1.0;
        }

        // Normalize the vector so diagonal movement isn't faster.
        // Without this, pressing W+D would result in sqrt(2) speed.
        if direction.length() > 0.0 {
            player.velocity = direction.normalize();
        } else {
            player.velocity = Vec2::ZERO;
        }
    }
}

The system accumulates direction from individual keys. It normalizes the result to prevent diagonal speed boost. It sets velocity to zero if no keys are pressed. This pattern works for any directional input. Replace WASD with arrow keys or gamepad axes as needed.

Modifiers and advanced state

Modifiers like Shift, Ctrl, and Alt live in a separate resource called ModifierKey. You query it alongside ButtonInput. This keeps the state clean. You check modifier.pressed(ModifierKey::Shift) to detect shift-hold.

use bevy::prelude::*;

/// Checks for Shift+A combination.
fn check_shift_combo(
    keyboard: Res<ButtonInput<KeyCode>>,
    modifiers: Res<ModifierKey>,
) {
    // Check if Shift is held and A is just pressed.
    // This pattern is common for shortcuts and modifiers.
    if modifiers.pressed(ModifierKey::Shift) && keyboard.just_pressed(KeyCode::KeyA) {
        println!("Shift+A pressed!");
    }
}

The ModifierKey resource tracks the state of modifier keys independently. This makes it easy to implement shortcuts and context-sensitive actions. You don't need to manually track modifier state in your systems. Bevy handles the aggregation.

Convention: Query ModifierKey as a separate resource. Don't try to derive modifiers from ButtonInput. The dedicated resource is more reliable and clearer.

Pitfalls and compiler errors

Confusion between pressed and just_pressed causes the "rocket jump" bug. pressed returns true as long as the key is held. just_pressed returns true only on the frame the key transitions from up to down. Use just_pressed for actions like jumping or firing. Use pressed for continuous actions like moving or aiming.

Schedule order breaks input. If you run your input system before Bevy processes input, you read the state from the previous frame. The character moves one frame late. Bevy runs input processing in the InputSystem schedule, which defaults to running before Update. If you put your system in Update, you're safe. If you create a custom schedule, ensure it runs after input processing.

KeyCode vs legacy Key. Bevy switched to KeyCode to support multiple keyboards and better ergonomics. Old tutorials reference Key::Space. The compiler rejects this with a mismatched types error or a missing item error. Always use KeyCode::Space.

Using EventReader<KeyInput> for simple state checks adds unnecessary complexity. EventReader streams individual events. It's useful for raw event details, but overkill for checking if a key is pressed. Stick to ButtonInput for state queries.

Check just_pressed for taps. Check pressed for holds. The compiler won't stop you from using the wrong one, but your gameplay will suffer.

Decision matrix

Use ButtonInput<KeyCode> when you need to check the state of keyboard keys for gameplay logic. This resource gives you pressed and just_pressed queries that are frame-perfect and easy to read.

Use ButtonInput<MouseButton> when you handle mouse clicks and holds. The API mirrors keyboard input, so you check MouseButton::Left the same way you check KeyCode::Space.

Use EventReader<KeyInput> when you need raw event details like modifiers, repeat counts, or when you want to react to input outside the standard button state model. This approach streams individual events rather than providing a snapshot.

Use ModifierKey when you need to detect Shift, Ctrl, Alt, or other modifier states. This resource aggregates modifier state separately from regular keys, making shortcuts and context checks cleaner.

Use InputSystem::ProcessInput scheduling when you must guarantee your system runs immediately after input updates but before other game logic. This avoids the one-frame delay that can happen if you rely on default ordering in complex schedules.

Input is data. Treat it like data. Query the resource, check the state, act.

Where to go next