saddle-vehicle-ground-vehicle

Reusable ground vehicle controller for Bevy with suspension, steering, grip, and crate-local lab verification


Reusable ground-vehicle controller for Bevy with Avian3D-backed suspension sampling, wheel steering, configurable powertrain strategies, tire grip, stability helpers, wheel-visual sync, and crate-local verification examples.

The crate is designed as a toolkit for cars, trucks, utility vehicles, and track-style rigs. It owns chassis and wheel force generation, but it does not own cameras, HUD, damage, missions, or game-specific vehicle genres.

Quick Start

[dependencies]
bevy = "0.18"
saddle-vehicle-ground-vehicle = { git = "https://github.com/julien-blanchon/saddle-vehicle-ground-vehicle" }
[dependencies]
bevy = "0.18"
saddle-vehicle-ground-vehicle = { git = "https://github.com/julien-blanchon/saddle-vehicle-ground-vehicle" }
use avian3d::prelude::*;
use bevy::prelude::*;
use saddle_vehicle_ground_vehicle::{
    GroundVehicle, GroundVehiclePlugin, GroundVehicleWheel, GroundVehicleWheelVisual,
    VehicleIntent, WheelSide,
};

#[derive(States, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum DemoState {
    #[default]
    Running,
}

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, PhysicsPlugins::default()))
        .init_state::<DemoState>()
        .add_plugins(GroundVehiclePlugin::new(
            OnEnter(DemoState::Running),
            OnExit(DemoState::Running),
            FixedUpdate,
        ))
        .insert_resource(Time::<Fixed>::from_hz(60.0))
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    let vehicle = GroundVehicle::default();
    let chassis = commands
        .spawn((
            Name::new("Demo Chassis"),
            vehicle,
            VehicleIntent::default(),
            Collider::cuboid(1.8, 0.7, 4.2),
            Transform::from_xyz(0.0, 1.1, 0.0),
        ))
        .id();

    let wheel_visual = commands
        .spawn((
            Name::new("Front Left Wheel Visual"),
            Mesh3d(meshes.add(Cylinder::new(0.36, 0.24))),
            MeshMaterial3d(materials.add(StandardMaterial::default())),
            Transform::from_translation(Vec3::new(-0.82, 0.45, -1.25)),
        ))
        .id();

    commands.spawn((
        Name::new("Front Left Wheel"),
        GroundVehicleWheel::default_front(
            chassis,
            Vec3::new(-0.82, -0.15, -1.25),
            WheelSide::Left,
        ),
        GroundVehicleWheelVisual {
            visual_entity: wheel_visual,
            base_rotation: Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
            ..default()
        },
    ));
}
use avian3d::prelude::*;
use bevy::prelude::*;
use saddle_vehicle_ground_vehicle::{
    GroundVehicle, GroundVehiclePlugin, GroundVehicleWheel, GroundVehicleWheelVisual,
    VehicleIntent, WheelSide,
};

#[derive(States, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum DemoState {
    #[default]
    Running,
}

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, PhysicsPlugins::default()))
        .init_state::<DemoState>()
        .add_plugins(GroundVehiclePlugin::new(
            OnEnter(DemoState::Running),
            OnExit(DemoState::Running),
            FixedUpdate,
        ))
        .insert_resource(Time::<Fixed>::from_hz(60.0))
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    let vehicle = GroundVehicle::default();
    let chassis = commands
        .spawn((
            Name::new("Demo Chassis"),
            vehicle,
            VehicleIntent::default(),
            Collider::cuboid(1.8, 0.7, 4.2),
            Transform::from_xyz(0.0, 1.1, 0.0),
        ))
        .id();

    let wheel_visual = commands
        .spawn((
            Name::new("Front Left Wheel Visual"),
            Mesh3d(meshes.add(Cylinder::new(0.36, 0.24))),
            MeshMaterial3d(materials.add(StandardMaterial::default())),
            Transform::from_translation(Vec3::new(-0.82, 0.45, -1.25)),
        ))
        .id();

    commands.spawn((
        Name::new("Front Left Wheel"),
        GroundVehicleWheel::default_front(
            chassis,
            Vec3::new(-0.82, -0.15, -1.25),
            WheelSide::Left,
        ),
        GroundVehicleWheelVisual {
            visual_entity: wheel_visual,
            base_rotation: Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
            ..default()
        },
    ));
}

For examples and crate-local labs, GroundVehiclePlugin::default() is the always-on entrypoint. It activates on PostStartup, never deactivates, and updates in FixedUpdate.

Coordinate And Unit Conventions

  • Distances: meters
  • Velocities: meters per second
  • Angles: radians
  • Forces: newtons
  • Torques: newton-meters
  • Chassis forward: Transform::forward() which in Bevy is local -Z
  • Chassis right: local +X
  • Chassis up: local +Y
  • Wheel mount_point: chassis-local position of the suspension origin
  • Chassis entities use Avian TransformInterpolation by default so fixed-step motion renders smoothly between physics ticks
  • Wheel visuals are separate from the physics chassis and are written in PostUpdate

Public API

TypePurpose
GroundVehiclePluginRegisters the runtime with injectable activate, deactivate, and update schedules
GroundVehicleSystemsPublic ordering hooks: InputAdaptation, Suspension, Steering, Powertrain, Grip, Stability, Telemetry, VisualSync
GroundVehicleChassis-level authored config: mass, inertia, steering, powertrain, stability, aero
VehicleIntentGeneric driver or AI intent: signed drive, signed turn, brake, and auxiliary brake
GroundVehicleWheelWheel authoring data: location, axle, drive/steer/brake role, suspension, tire
GroundVehicleWheelVisualBinding from wheel runtime state to a visible mesh entity
GroundVehicleWheelStatePer-wheel runtime contact, load, slip, force, steer, and spin state
GroundVehicleTelemetryChassis-level runtime speed, grounded-wheel, normal, engine RPM, and selected-gear aggregation
GroundVehicleSurfaceOptional surface multipliers for grip, rolling drag, and braking
GroundVehicleResetMarker component: insert to teleport the vehicle and zero its velocities and wheel state
GroundVehicleDebugDrawRuntime gizmo toggles for suspension, contact, force, and slip vectors
SteeringConfig, PowertrainConfig, EngineConfig, DriveModel, GearModel, AutomaticGearboxConfig, FixedGearConfig, DifferentialConfig, SuspensionConfig, TireGripConfig, MagicFormulaConfig, StabilityConfig, AerodynamicsConfigTunable sub-configs used by authored chassis and wheel data
GroundVehicleDriftPlugin, GroundVehicleDriftConfig, GroundVehicleDriftTelemetry, DriftStateChangedOptional drift helper layer for slip-based drift telemetry and drift state messages
WheelGroundedChanged, VehicleBecameAirborne, VehicleLandedCore messages for gameplay reactions, UI, VFX, or tuning tools

The crate intentionally does not expose internal solver scratch state, axle accumulators, or force-request bookkeeping.

Powertrain Model

PowertrainConfig separates the power source from the delivery strategy:

  • engine: torque curve and engine-braking behavior
  • drive_model: how torque is distributed, currently DriveModel::Axle or DriveModel::Track
  • gear_model: how ratio selection works, currently GearModel::Automatic or GearModel::Fixed
  • brake_force_newtons / auxiliary_brake_force_newtons: explicit brake budgets

This keeps the input surface generic while letting road vehicles, multi-axle trucks, and track-drive rigs share the same chassis and tire systems.

Optional Drift Helper

Drift telemetry is not part of the core runtime anymore.

Add the optional helper when a game or example actually wants drift state:

use saddle_vehicle_ground_vehicle::{
    GroundVehicleDriftConfig, GroundVehicleDriftPlugin, GroundVehiclePlugin,
};

App::new()
    .add_plugins((
        GroundVehiclePlugin::default(),
        GroundVehicleDriftPlugin::default(),
    ))
    .add_systems(Startup, |mut commands: Commands| {
        commands.spawn((
            Name::new("Drift-Capable Vehicle"),
            GroundVehicle::default(),
            VehicleIntent::default(),
            GroundVehicleDriftConfig::default(),
        ));
    });
use saddle_vehicle_ground_vehicle::{
    GroundVehicleDriftConfig, GroundVehicleDriftPlugin, GroundVehiclePlugin,
};

App::new()
    .add_plugins((
        GroundVehiclePlugin::default(),
        GroundVehicleDriftPlugin::default(),
    ))
    .add_systems(Startup, |mut commands: Commands| {
        commands.spawn((
            Name::new("Drift-Capable Vehicle"),
            GroundVehicle::default(),
            VehicleIntent::default(),
            GroundVehicleDriftConfig::default(),
        ));
    });

Attach GroundVehicleDriftConfig to the same entity as GroundVehicle. The drift helper then writes GroundVehicleDriftTelemetry and emits DriftStateChanged when the drift state toggles.

Supported Vehicle Styles

  • Four-wheel road vehicles with Ackermann steering
  • Rear-biased drift cars through auxiliary-brake shaping plus linear or Magic Formula tire response
  • Long-travel off-road and utility vehicles
  • Multi-axle cargo trucks
  • Left/right track-drive or skid-steer style vehicles through DriveModel::Track
  • Single-speed or automatic geared powertrains
  • Narrow motorcycle-feel vehicles with always-on upright stabilization (roll_upright_torque_nm_per_rad)
  • Sim-racing cars with MagicFormula tires and aerodynamic downforce
  • Lightweight arcade karts with exaggerated grip and instant response
  • Heavy open-world sedans with forgiving stability and airborne self-righting

What The Crate Does Not Do

  • Full clutch simulation, engine-audio playback, or drivetrain damage
  • Tire temperature, wear, and full motorsport-grade multi-point tire fitting
  • Full tread simulation for tracks
  • Camera rigs, HUD, replay, or networking
  • Damage, deformation, or mission-specific gameplay rules
  • Genre presets in the core API

The old arcade/sim/off-road presets were intentionally removed from the core crate. Example-specific presets now live in examples/support where they do not constrain the reusable public API.

Examples

All example apps include live saddle-pane tuning and on-screen controls. The example support crate also adds the optional drift helper so drift telemetry is available in the demos and lab.

ExamplePurposeRun
basicMinimal four-wheel hatchback on a flat handling padcargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_basic
multi_axleSix-wheel truck across bumps and uneven supportcargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_multi_axle
drift_tuningRear-biased drift coupe using the Magic Formula tire path for controllable breakawaycargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_drift_tuning
driving_demoCheckpoint-based canyon driving demo with a scripted tiltrotor escort from saddle-vehicle-flightcargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_driving_demo
skid_steerLeft/right track-drive steering for tank-like or tracked-style controlcargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_skid_steer
slope_stabilityHill hold, anti-roll, and low-speed traction on ramps and off-camber surfacescargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_slope_stability
sport_bikeNarrow, light motorcycle-feel vehicle with quick steering and slalom coursecargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_sport_bike
sim_racingRealistic RWD sports car with MagicFormula tires, stiff suspension, and aero downforcecargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_sim_racing
kart_racingPlayful Mario Kart-style kart with boost pads, ramps, oil patches, and dirt shortcutscargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_kart_racing
open_worldGTA-like heavy sedan with dynamic crates, bollards, ramps, and strong self-rightingcargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_open_world

Most reusable vehicle presets are verified through the shared lab below. The checkpoint-focused driving_demo keeps its own example-local E2E flow because its lap progression and HUD logic only exist in that example package:

cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_driving_demo --features e2e -- driving_demo_checkpoint_lap
cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_example_driving_demo --features e2e -- driving_demo_checkpoint_lap

Crate-Local Lab

The richer standalone verification app lives under examples/lab:

cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_lab
cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_lab

E2E Scenarios

The lab includes 11 automated E2E scenarios powered by saddle-bevy-e2e. Each scenario resets a specific vehicle, applies scripted inputs, captures screenshots, and runs soft assertions. Run them with:

cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_lab --features e2e -- <scenario_name>
cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_lab --features e2e -- <scenario_name>
ScenarioVehicleWhat it verifies
ground_vehicle_smokeCompact carSettles on ground, builds forward speed under throttle, stays out of drift
ground_vehicle_brakingCompact carBuilds speed then brakes to a stop, maintains ground contact, no wild yaw
ground_vehicle_drivetrainCompact carUpshifts under sustained throttle, engine RPM stays in valid range
ground_vehicle_slopeRoverHolds position on an inclined ramp under brake, stays grounded, detects slope normal
ground_vehicle_driftDrift coupeEnters a drift with throttle + turn + aux brake, shows lateral movement, stays grounded
ground_vehicle_skid_steerSkid vehicleYaws via left/right drive split (not wheel steer), keeps all wheels on ground
ground_vehicle_multi_axleCargo truckStays upright and grounded while crossing a bump course, no drift state
ground_vehicle_kart_racingKartUses the actual kart setup through its arcade lane and verifies planted, high-speed handling
ground_vehicle_sport_bikeSport bikeUses the actual bike-style setup and verifies upright stability during a fast turn
ground_vehicle_sim_racingSim racerUses the actual race-car setup and verifies clean upshifts plus low drift ratio
ground_vehicle_open_worldOpen-world sedanUses the forgiving sedan against dynamic props and verifies post-impact stability

Each scenario writes its output to e2e_output/<scenario_name>/ relative to the directory you launch from:

  • log.txt — timestamped action log with pass/fail results
  • *.png — screenshots at key moments (start, mid, end states)

Add --handoff to keep the app running after the scenario for interactive debugging:

cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_lab --features e2e -- ground_vehicle_smoke --handoff
cargo run --manifest-path examples/Cargo.toml -p ground_vehicle_lab --features e2e -- ground_vehicle_smoke --handoff

Resetting Vehicles

When teleporting a vehicle (e.g. for respawn or scenario reset), use reset_vehicle_state to flush the internal wheel/powertrain state immediately. Without this, stale suspension history causes a damper force spike on the first physics frame after teleport:

// In an exclusive system or Custom E2E action with &mut World:
*world.get_mut::<Transform>(chassis).unwrap() = new_transform;
*world.get_mut::<LinearVelocity>(chassis).unwrap() = LinearVelocity::ZERO;
*world.get_mut::<AngularVelocity>(chassis).unwrap() = AngularVelocity::ZERO;
ground_vehicle::reset_vehicle_state(world, chassis);
// In an exclusive system or Custom E2E action with &mut World:
*world.get_mut::<Transform>(chassis).unwrap() = new_transform;
*world.get_mut::<LinearVelocity>(chassis).unwrap() = LinearVelocity::ZERO;
*world.get_mut::<AngularVelocity>(chassis).unwrap() = AngularVelocity::ZERO;
ground_vehicle::reset_vehicle_state(world, chassis);

BRP

ground_vehicle_lab uses BRP port 15712 by default to avoid collisions with other local Bevy apps. Override with GROUND_VEHICLE_LAB_BRP_PORT if needed.

BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp app launch ground_vehicle_lab
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp world query ground_vehicle::components::GroundVehicleTelemetry
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp world query ground_vehicle::drift::GroundVehicleDriftTelemetry
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp world query ground_vehicle::components::GroundVehicleWheelState
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp extras screenshot /tmp/ground_vehicle_lab.png
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp extras shutdown
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp app launch ground_vehicle_lab
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp world query ground_vehicle::components::GroundVehicleTelemetry
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp world query ground_vehicle::drift::GroundVehicleDriftTelemetry
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp world query ground_vehicle::components::GroundVehicleWheelState
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp extras screenshot /tmp/ground_vehicle_lab.png
BRP_PORT=15712 uv run --active --project .codex/skills/bevy-brp/script brp extras shutdown

More Docs