saddle-systems-game-feel


Reusable Bevy feedback runtime for impactful moment-to-moment response: trauma shake, hitstop, camera punch, time scaling, flash pulses, rumble outputs, squash and stretch, knockback, and data-driven recipes.

The crate stays project-agnostic. It does not depend on game_core, Screen, GameSet, asset paths, or any gameplay vocabulary. Consumer crates emit generic feedback messages, and saddle-systems-game-feel handles stacking, timing, cleanup, and the built-in transform/sprite/screen adapters.

For always-on examples, tools, or sandboxes, GameFeelPlugin::always_on(Update) is the simplest entrypoint. For real games, prefer GameFeelPlugin::new(...) so activation and teardown stay aligned with your own schedules.

Core defaults stay blank on purpose:

  • FeedbackRecipeLibrary::default() starts empty
  • GameFeelChannels only exposes raw bit data plus NONE / ALL
  • optional example-friendly recipe packs and channel aliases live under saddle_systems_game_feel::presets

Quick Start

[dependencies]
bevy = "0.18"
saddle-systems-game-feel = { git = "https://github.com/julien-blanchon/saddle-systems-game-feel" }
[dependencies]
bevy = "0.18"
saddle-systems-game-feel = { git = "https://github.com/julien-blanchon/saddle-systems-game-feel" }
use bevy::prelude::*;
use saddle_systems_game_feel::*;

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

#[derive(Component)]
struct DemoCamera;

#[derive(Component)]
struct DemoTarget;

#[derive(Resource)]
struct TriggerTimer(Timer);

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .init_state::<DemoState>()
        .insert_resource(TriggerTimer(Timer::from_seconds(
            0.85,
            TimerMode::Repeating,
        )))
        .add_plugins(GameFeelPlugin::new(
            OnEnter(DemoState::Gameplay),
            OnExit(DemoState::Gameplay),
            Update,
        ))
        .add_systems(Startup, setup)
        .add_systems(Update, trigger_hit)
        .run();
}

fn setup(mut commands: Commands) {
    let camera = commands
        .spawn((
            Name::new("Gameplay Camera"),
            DemoCamera,
            Camera2d,
            ShakeListener::default(),
            PunchListener::default(),
            ScreenPulseListener::default(),
            Transform::from_xyz(0.0, 0.0, 10.0),
        ))
        .id();

    commands.spawn((
            Name::new("Target"),
            DemoTarget,
            Sprite::from_color(Color::srgb(0.9, 0.3, 0.2), Vec2::splat(96.0)),
            Transform::default(),
        ));

    let _ = camera;
}

fn trigger_hit(
    time: Res<Time>,
    mut timer: ResMut<TriggerTimer>,
    camera: Query<Entity, With<DemoCamera>>,
    target: Query<Entity, With<DemoTarget>>,
    mut trauma: MessageWriter<AddTrauma>,
    mut hitstop: MessageWriter<RequestHitstop>,
    mut flash: MessageWriter<RequestFlash>,
) {
    if !timer.0.tick(time.delta()).just_finished() {
        return;
    }

    let Ok(camera) = camera.single() else {
        return;
    };
    let Ok(target) = target.single() else {
        return;
    };
    trauma.write(
        AddTrauma::new(ListenerTarget::Entity(camera), 0.28)
            .with_directional_bias(Vec3::new(0.08, -0.02, 0.0)),
    );
    hitstop.write(
        RequestHitstop::new(TimeScaleTarget::World, 3)
            .with_recovery(4),
    );
    flash.write(
        RequestFlash::new(
            FlashTarget::EntityAndScreen {
                entity: target,
                screen: ListenerTarget::Entity(camera),
            },
            Color::WHITE,
            0.45,
        )
        .with_chromatic_aberration(0.05)
        .with_vignette(0.12),
    );
}
use bevy::prelude::*;
use saddle_systems_game_feel::*;

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

#[derive(Component)]
struct DemoCamera;

#[derive(Component)]
struct DemoTarget;

#[derive(Resource)]
struct TriggerTimer(Timer);

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .init_state::<DemoState>()
        .insert_resource(TriggerTimer(Timer::from_seconds(
            0.85,
            TimerMode::Repeating,
        )))
        .add_plugins(GameFeelPlugin::new(
            OnEnter(DemoState::Gameplay),
            OnExit(DemoState::Gameplay),
            Update,
        ))
        .add_systems(Startup, setup)
        .add_systems(Update, trigger_hit)
        .run();
}

fn setup(mut commands: Commands) {
    let camera = commands
        .spawn((
            Name::new("Gameplay Camera"),
            DemoCamera,
            Camera2d,
            ShakeListener::default(),
            PunchListener::default(),
            ScreenPulseListener::default(),
            Transform::from_xyz(0.0, 0.0, 10.0),
        ))
        .id();

    commands.spawn((
            Name::new("Target"),
            DemoTarget,
            Sprite::from_color(Color::srgb(0.9, 0.3, 0.2), Vec2::splat(96.0)),
            Transform::default(),
        ));

    let _ = camera;
}

fn trigger_hit(
    time: Res<Time>,
    mut timer: ResMut<TriggerTimer>,
    camera: Query<Entity, With<DemoCamera>>,
    target: Query<Entity, With<DemoTarget>>,
    mut trauma: MessageWriter<AddTrauma>,
    mut hitstop: MessageWriter<RequestHitstop>,
    mut flash: MessageWriter<RequestFlash>,
) {
    if !timer.0.tick(time.delta()).just_finished() {
        return;
    }

    let Ok(camera) = camera.single() else {
        return;
    };
    let Ok(target) = target.single() else {
        return;
    };
    trauma.write(
        AddTrauma::new(ListenerTarget::Entity(camera), 0.28)
            .with_directional_bias(Vec3::new(0.08, -0.02, 0.0)),
    );
    hitstop.write(
        RequestHitstop::new(TimeScaleTarget::World, 3)
            .with_recovery(4),
    );
    flash.write(
        RequestFlash::new(
            FlashTarget::EntityAndScreen {
                entity: target,
                screen: ListenerTarget::Entity(camera),
            },
            Color::WHITE,
            0.45,
        )
        .with_chromatic_aberration(0.05)
        .with_vignette(0.12),
    );
}

Public API

TypePurpose
GameFeelPluginRegisters the runtime with injectable activate, deactivate, and update schedules
GameFeelSystemsPublic ordering hooks: ProcessRequests, UpdateSimulation, ApplyOutputs, Cleanup
ScreenPulsePresentationChooses output-only screen pulses or the legacy built-in overlay/chromatic adapter
AddTraumaRequest trauma-based shake on listeners
RequestCameraImpulseRequest spring-damped translational / rotational / FOV punch
RequestHitstop / RequestSplitHitstopFrame-counted freeze requests for world or targeted entities
RequestTimeScaleRamp/hold/recover time-scale requests for world or targeted entities
RequestFlashEntity flash plus screen pulse request with optional distance attenuation
RequestRumbleOutput-only haptics / rumble request for one listener, a channel group, or all listeners
RequestSquashStretchMessage-driven scale feedback on a target entity
RequestKnockbackDisplacement-based knockback on a target entity with eased decay
PlayFeedbackRecipeSingle trigger message for any recipe loaded into FeedbackRecipeLibrary
presets::{channels, recipes}Optional example-oriented channel aliases and named recipe packs
GameFeelTogglesResource to enable/disable individual effect categories at runtime
ShakeListener, PunchListener, ScreenPulseListener, RumbleListenerOpt-in listeners for shake, punch, screen effects, and haptics
KnockbackReceiverOpt-in component for entities that can receive knockback displacement
GlobalTimeScale, EntityTimeScalePublic timing surfaces for consumer gameplay systems
ShakeState, PunchState, FlashOutput, ScreenPulseOutput, RumbleOutput, SquashStretchState, KnockbackStateInspectable output surfaces and adapter bridge points
FeedbackRecipeLibrary, FeedbackRecipe, FeedbackAction, FeedbackCondition, FeedbackRecipeRepeatData-driven recipe authoring surface, blank-by-default library resource, conditional playback, and loop control
RecipeRumble, RecipeKnockbackRecipe-authored haptics and knockback output
RecipeHooks, FeedbackHookTriggeredBridge recipe steps into audio, particles, or custom renderer integrations
Tween, TweenRepeat, AttackSustainDecayLightweight easing and envelope helpers reused internally and available to consumers

Stacking Rules

FamilyRule
Trauma shakeAdditive with clamp to [0, 1], per-frame budget, and fatigue damping
PunchVelocity impulses sum; the spring returns to baseline
HitstopExplicit policy per request: Refresh, Max, AdditiveWithCap, IgnoreWeaker
Time scaleHighest priority wins; ties resolve to the lowest scale
Entity flashStrongest weighted intensity wins
Screen pulseFlash color uses the strongest pulse; chromatic and vignette use max composition
Squash/stretchConfigurable Multiply, Strongest, or Replace stacking
KnockbackAll active displacements sum; KnockbackReceiver clamps the total magnitude

Time Semantics

  • GlobalTimeScale is a crate-owned timing surface, not a mutation of Bevy's global virtual time.
  • EntityTimeScale is the per-entity equivalent for consumer systems that need local slow-mo or exemption handling.
  • Every authored effect chooses EffectTimeDomain::Unscaled or EffectTimeDomain::GlobalScaled.
  • Hitstop uses deterministic frame counts, while punch, flash, and scale effects advance on explicit time domains.

This keeps the crate generic: it can drive feel effects even when a project has its own fixed-step or pause model.

Built-In Adapters

The runtime ships with a generic core plus a few concrete adapters:

  • transform application for ShakeState, PunchState, and SquashStretchState
  • perspective FOV punch for Projection::Perspective
  • sprite-color flash application from FlashOutput
  • optional screen overlay and chromatic aberration application from ScreenPulseOutput when GameFeelConfig.screen_presentation = ScreenPulsePresentation::LegacyBuiltIn

FlashOutput remains the material-style extension point for projects that want custom shader or material adapters. ScreenPulseOutput is likewise the recommended bridge into crates like saddle-rendering-screen-effects when projects want one unified post-process stack instead of multiple parallel overlay systems.

Optional preset recipes from presets::recipes also emit FeedbackHookTriggered cue messages so audio, particles, and other presentation layers can subscribe without taking a hard dependency on any specific backend.

Optional Presets

If you want the crate's example-oriented recipe pack and channel names, opt in explicitly:

use saddle_systems_game_feel::{GameFeelPlugin, PlayFeedbackRecipe, presets};

App::new()
    .add_plugins(GameFeelPlugin::default())
    .insert_resource(presets::recipes::library());

// Later:
// recipes.write(
//     PlayFeedbackRecipe::new(presets::recipes::HEAVY_IMPACT)
//         .with_channels(presets::channels::WEAPON),
// );
use saddle_systems_game_feel::{GameFeelPlugin, PlayFeedbackRecipe, presets};

App::new()
    .add_plugins(GameFeelPlugin::default())
    .insert_resource(presets::recipes::library());

// Later:
// recipes.write(
//     PlayFeedbackRecipe::new(presets::recipes::HEAVY_IMPACT)
//         .with_channels(presets::channels::WEAPON),
// );

Examples

ExamplePurposeRunE2E
basicMinimal self-running shake and punch loopcargo run -p saddle-systems-game-feel-example-basiccargo run -p saddle-systems-game-feel-example-basic --features e2e -- game_feel_basic_loop
hitstopHitstop plus flash plus squash on a moving targetcargo run -p saddle-systems-game-feel-example-hitstopcargo run -p saddle-systems-game-feel-example-hitstop --features e2e -- game_feel_hitstop_cycle
recipesOptional preset recipe playback loopcargo run -p saddle-systems-game-feel-example-recipescargo run -p saddle-systems-game-feel-example-recipes --features e2e -- game_feel_recipes_cycle
combo_systemTwo-cycle finisher combo that exercises conditional recipes, hook cues, and rumble outputscargo run -p saddle-systems-game-feel-example-combo-systemcargo run -p saddle-systems-game-feel-example-combo-system --features e2e -- game_feel_combo_system_cycle
time_scaleWorld slow-mo with an entity that ignores global scalingcargo run -p saddle-systems-game-feel-example-time_scalecargo run -p saddle-systems-game-feel-example-time_scale --features e2e -- game_feel_time_scale_pulse
recoil_3dPerspective-camera recoil example proving 3D transform and FOV punch supportcargo run -p saddle-systems-game-feel-example-recoil_3dcargo run -p saddle-systems-game-feel-example-recoil_3d --features e2e -- game_feel_recoil_3d_cycle
debug_showcaseRich self-running showcase of recoil plus optional impact, explosion, and reward presetscargo run -p saddle-systems-game-feel-example-debug_showcasecargo run -p saddle-systems-game-feel-example-debug_showcase --features e2e -- game_feel_debug_showcase_cycle
punchingInteractive punch bag with full effect stack: shake, hitstop, flash, squash, knockback, and recipecargo run -p saddle-systems-game-feel-example-punchingcargo run -p saddle-systems-game-feel-example-punching --features e2e -- game_feel_punching_strike
comparisonSide-by-side with/without game feel; press T to toggle all effectscargo run -p saddle-systems-game-feel-example-comparisoncargo run -p saddle-systems-game-feel-example-comparison --features e2e -- game_feel_comparison_toggle
platformerPlatformer feel: landing squash, jump stretch, wall shake, and recipe-driven impactscargo run -p saddle-systems-game-feel-example-platformercargo run -p saddle-systems-game-feel-example-platformer --features e2e -- game_feel_platformer_jump_land
shooterWeapon fire, light/heavy hits, and parry via the optional preset recipe packcargo run -p saddle-systems-game-feel-example-shootercargo run -p saddle-systems-game-feel-example-shooter --features e2e -- game_feel_shooter_fire_and_hit

Every shipped example now includes a saddle-pane panel for live timing and intensity tuning.

Workspace Lab

The crate-local lab lives at shared/systems/saddle-systems-game-feel/examples/lab:

cargo run -p saddle-systems-game-feel-lab
cargo run -p saddle-systems-game-feel-lab

It is the primary BRP and E2E verification target for this crate.

The lab now verifies recipe step playback, recipe hook emission, and rumble output activity, so the output-only integration path is exercised alongside the visual feedback path.

Each shipped example also exposes a direct E2E scenario behind --features e2e, so interactive flows like punching, platformer movement, shooter hits, comparison toggling, and the real 3D recoil scene are verified in their own app instead of only through the shared lab.

More Docs

Current Limitations

  • The built-in flash adapter only writes Sprite color directly. Material or shader-backed flash should read FlashOutput and apply it in project-specific render code.
  • Screen pulses default to ScreenPulsePresentation::OutputOnly so projects can bridge them into a shared post-process stack. The legacy overlay/chromatic adapter still exists for sandboxes and examples.
  • Time scaling is exposed as public resources/components; the crate does not mutate Bevy's global Time<Virtual> or freeze arbitrary gameplay systems automatically.
  • The crate-local lab still concentrates on one deterministic 2D scene so its screenshots stay comparable, even though the individual example packages now also ship their own E2E entrypoints for interactive and 3D verification.
  • Projects that need custom 3D material flashes or heavier renderer-specific post-processing will still usually add project-specific adapters on top of FlashOutput or ScreenPulseOutput.