saddle-character-controller

Reusable 3D kinematic character controller for Bevy using Avian3D with optional adapter and convenience layers


Reusable 3D kinematic character controller for Bevy, built on avian3d.

The controller is a pure body simulation — it handles physics, grounding, movement, and collisions. It does not own orientation or camera logic. Orientation is a Quat on CharacterControllerState that external code (a camera controller, AI, or your game) writes to.

Abilities (dash, flying, mantling, wall-kick, swimming) are opt-in plugins — add only what your game needs.

Quick Start

use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
use saddle_character_controller::{
    CharacterController,
    CharacterControllerPlugin,
    CharacterFlying,
    adapters::enhanced_input::{
        CharacterControllerEnhancedInputPlugin, CrouchAction, JumpAction,
        MoveAction, SprintAction,
    },
    abilities::{
        flying::CharacterControllerFlyingPlugin,
        dash::CharacterControllerDashPlugin,
    },
};

fn main() {
    App::new()
        .insert_resource(Time::<Fixed>::from_hz(60.0))
        .add_plugins((DefaultPlugins, PhysicsPlugins::default()))
        .add_plugins((
            // Core body simulation
            CharacterControllerPlugin::always_on(FixedUpdate),
            CharacterControllerEnhancedInputPlugin,
            // Pick only the abilities you need:
            CharacterControllerFlyingPlugin,
            CharacterControllerDashPlugin,
        ))
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn((
        Name::new("Player"),
        CharacterController::default(),
        CharacterFlying::default(), // only because we added FlyingPlugin
        Transform::from_xyz(0.0, 2.0, 0.0),
        actions!(CharacterController[
            (
                Action::<MoveAction>::new(),
                DeadZone::default(),
                Bindings::spawn((Cardinal::wasd_keys(), Axial::left_stick())),
            ),
            (Action::<JumpAction>::new(), bindings![KeyCode::Space, GamepadButton::South]),
            (Action::<SprintAction>::new(), bindings![KeyCode::ShiftLeft]),
            (Action::<CrouchAction>::new(), bindings![KeyCode::ControlLeft]),
        ]),
    ));
    // Orientation: write to CharacterControllerState::orientation from your camera or input system.
}
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
use saddle_character_controller::{
    CharacterController,
    CharacterControllerPlugin,
    CharacterFlying,
    adapters::enhanced_input::{
        CharacterControllerEnhancedInputPlugin, CrouchAction, JumpAction,
        MoveAction, SprintAction,
    },
    abilities::{
        flying::CharacterControllerFlyingPlugin,
        dash::CharacterControllerDashPlugin,
    },
};

fn main() {
    App::new()
        .insert_resource(Time::<Fixed>::from_hz(60.0))
        .add_plugins((DefaultPlugins, PhysicsPlugins::default()))
        .add_plugins((
            // Core body simulation
            CharacterControllerPlugin::always_on(FixedUpdate),
            CharacterControllerEnhancedInputPlugin,
            // Pick only the abilities you need:
            CharacterControllerFlyingPlugin,
            CharacterControllerDashPlugin,
        ))
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn((
        Name::new("Player"),
        CharacterController::default(),
        CharacterFlying::default(), // only because we added FlyingPlugin
        Transform::from_xyz(0.0, 2.0, 0.0),
        actions!(CharacterController[
            (
                Action::<MoveAction>::new(),
                DeadZone::default(),
                Bindings::spawn((Cardinal::wasd_keys(), Axial::left_stick())),
            ),
            (Action::<JumpAction>::new(), bindings![KeyCode::Space, GamepadButton::South]),
            (Action::<SprintAction>::new(), bindings![KeyCode::ShiftLeft]),
            (Action::<CrouchAction>::new(), bindings![KeyCode::ControlLeft]),
        ]),
    ));
    // Orientation: write to CharacterControllerState::orientation from your camera or input system.
}

Architecture

Core Plugin

CharacterControllerPlugin provides the body simulation frame:

System SetPurpose
ReadInputTick input buffers
PreMovementShape refresh, controller init
GroundingEnvironment detection (opt-in)
MovementPrepareDepenetrate, ground probe, support, crouch, input expiry
MovementExecuteAbility plugins and core movement run here
MovementFinalizePost-ground probe, snap, stats, mode classify
PostMovementPush forces, collider sync, messages
PresentationDebug draw

Ability Plugins (opt-in)

PluginComponentPurpose
CharacterControllerFlyingPluginCharacterFlyingSpectator/flight mode
CharacterControllerDashPluginCharacterDashDirection-locked burst movement
CharacterControllerMantlePluginCharacterMantleLedge climbing
CharacterControllerWallKickPluginCharacterWallKickWall jump
CharacterControllerSwimmingPluginCharacterSwimmingWater movement

Add the plugin, attach the component to your entity, done.

Custom Movement

Write your own systems in MovementExecute. Set MovementOverride::active to suppress core movement when your custom logic is in control:

fn grapple_system(
    mut q: Query<(&mut LinearVelocity, &mut MovementOverride, &MyGrapple)>,
) {
    for (mut vel, mut mov, grapple) in &mut q {
        if grapple.active {
            mov.active = Some("grapple");
            mov.suppress_gravity = true;
            vel.0 = grapple.direction * grapple.speed;
        } else {
            mov.active = None;
        }
    }
}
fn grapple_system(
    mut q: Query<(&mut LinearVelocity, &mut MovementOverride, &MyGrapple)>,
) {
    for (mut vel, mut mov, grapple) in &mut q {
        if grapple.active {
            mov.active = Some("grapple");
            mov.suppress_gravity = true;
            vel.0 = grapple.direction * grapple.speed;
        } else {
            mov.active = None;
        }
    }
}

Core Types

TypePurpose
CharacterControllerMovement tuning: speed, gravity, jump, crouch, step size, etc.
AccumulatedInputBuffered input: move_axis, jump, sprint, crouch
CharacterControllerStateRuntime state: orientation, ground contact, movement mode, crouching
MovementOverrideAbility/custom movement takeover flag
CharacterMotionStatsRead-only stats: speed, grounded time, support entity
MovementModeGrounded, Airborne, Sliding, Custom(u8)
ExternalMotionExternal velocity injection
CharacterGravityPer-entity gravity override
CharacterPushPush dynamic bodies on contact
MessagesCharacterJumped, CharacterLanded, MovementModeChanged, SupportBodyChanged

Orientation

The controller does not own look/orientation. CharacterControllerState::orientation is a Quat that determines movement direction. External code writes it:

  • FPS camera: use saddle-camera-fps-camera with FpsCameraExternalMotion, sync runtime.yaw/pitchstate.orientation
  • Third person: write orientation from your camera's yaw
  • AI: set orientation toward the target

Adapter & Convenience

ModulePurpose
adapters::enhanced_inputbevy_enhanced_input action schema + plugin
convenience::environmentEnvironment volume detector + SwimVolume

Examples

ExamplePurpose
basicFlat ground, walk, jump, crouch, FPS camera
slopes_and_stairsSlope limits, step-up, snap behavior
moving_platformsPlatform support, detach grace, conveyor surfaces
advanced_movementAuto-bhop, surf-friendly surfaces, debug draw
traversalMantle and wall-kick obstacle course
waterSwimVolume entry, swimming, exit
third_personThird-person follow camera
stress_many_controllersMany-controller perf smoke test
labFull integration with animation state machine and IK

Run with: cargo run -p saddle-character-controller-example-basic