wrote a game feel autoload for Godot 4: hit stop, screen flash, and camera impulse in one call

484 views 10 replies

Every time I add a new attack or ability I end up writing the same three things: a brief Engine.time_scale dip for hit stop, a screen flash overlay, and a camera shake impulse. Been doing this per-mechanic for months. Finally sat down and consolidated it into a single autoload.

The API is just GameFeel.trigger(cfg) where cfg is a dictionary with the relevant keys. Hit stop duration, flash color and alpha, impulse strength and duration. Call it from anywhere and all three run concurrently.

extends Node

func trigger(cfg: Dictionary) -> void:
    if cfg.get("hit_stop", 0.0) > 0.0:
        _do_hit_stop(cfg["hit_stop"])
    if cfg.get("flash_color"):
        _do_flash(cfg["flash_color"], cfg.get("flash_duration", 0.08))
    if cfg.get("impulse_strength", 0.0) > 0.0:
        _do_camera_impulse(cfg["impulse_strength"], cfg.get("impulse_duration", 0.12))

func _do_hit_stop(duration: float) -> void:
    Engine.time_scale = 0.05
    await get_tree().create_timer(duration, true, false, true).timeout
    Engine.time_scale = 1.0

func _do_flash(color: Color, duration: float) -> void:
    var flash := get_tree().root.find_child("FlashRect", true, false) as ColorRect
    if not flash:
        return
    flash.color = color
    var tween := create_tween()
    tween.tween_property(flash, "color:a", 0.0, duration)

func _do_camera_impulse(strength: float, duration: float) -> void:
    var cam := get_viewport().get_camera_2d()
    if not cam:
        return
    var dir := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)).normalized()
    var tween := create_tween()
    tween.tween_property(cam, "offset", dir * strength, duration * 0.3)
    tween.tween_property(cam, "offset", Vector2.ZERO, duration * 0.7)

The gotcha with hit stop: create_timer's fourth argument is ignore_time_scale. Pass true there, otherwise your timer also slows down and the hit stop runs way longer than intended. Wasted an embarrassing amount of time on that before I actually read the docs.

The flash currently relies on finding a ColorRect named FlashRect in a persistent CanvasLayer somewhere in your scene tree. It works but it's fragile. I'm probably switching to a shader-based overlay on the autoload itself so I don't need that external dependency.

Camera impulse is still naive. One random direction, smooth return tween. No trauma system or perlin noise decay for sustained effects. Fine for individual hits, feels wrong for explosions or getting hit repeatedly. Planning to add a trauma value that accumulates on each trigger and decays over time so rapid hits stack properly instead of just retriggering the same jerk.

Anyone doing something similar? Particularly curious if you're using a Resource-based config so designers can tweak per-ability feel values in the inspector without touching code.

Replying to PixelLattice: one thing worth stacking on top of the trauma² magnitude mapping: use a FastNois...

+1 on FastNoiseLite over randf(). one thing that tripped me up early: the default noise frequency is way too low and the shake ends up feeling floaty instead of snappy. bumping it to the 4.0–6.0 range makes a big perceptual difference. you want quick oscillation, not slow drift. also even a tiny amount of domain warping (warp amplitude around 1.0–2.0) breaks up the regularity so the motion reads less "computed". took me a while to figure out why my shake felt wrong even when the trauma math was correct.

Replying to VertexLynx: squaring the trauma before using it as shake magnitude is the other piece of thi...

the trauma² mapping is right but worth clamping before squaring if hits can stack quickly. had a bug where two near-simultaneous impacts put trauma at about 1.08, and then trauma * trauma outputs 1.17 — visually reads as "more shake than max" in a way that feels subtly broken but is hard to pin down. shake_magnitude = clamp(trauma, 0.0, 1.0) ** 2 fixes it. obvious in retrospect but it took an embarrassingly long time to catch.

facepalm programmer debugging
Replying to VertexLynx: squaring the trauma before using it as shake magnitude is the other piece of thi...
one thing worth stacking on top of the trauma² magnitude mapping: use a FastNoiseLite sampled by time for the actual offset vector instead of raw randf(). sample two different frequencies for x and y. the shake still uses trauma as the magnitude scalar, but the direction comes from smooth noise rather than uniform random. pure random has this stuttery teleporting quality that noise smooths out, ends up feeling more like a camera on a real mount rather than a camera having a seizure.
Replying to BlazeMist: The one-call convenience is great, but I ended up breaking mine into optional fl...

yeah same conclusion here. one thing i stacked on top: a guard that bails early if the instigating node is already is_queued_for_deletion() before any tween fires. Was getting phantom camera shakes during enemy cleanup sequences, hit stop running against a node that was already halfway gone. took embarrassingly long to track down. ghost appears out of nowhere startled

splitting the flags also made per-ability tuning way cleaner. can actually iterate on flash timing without accidentally touching hit stop duration, which used to cause constant regressions when tweaking ability feel.

Replying to OnyxByte: One extension I added to a similar system: a persistent trauma float that accumu...

trauma accumulation is the right call. one thing that made a big difference in how it feels: decay nonlinearly instead of linear. instead of trauma -= decay_rate * delta, try:

trauma = move_toward(trauma, 0.0, trauma * trauma * delta * decay_rate)

snaps down fast when trauma is high (no lingering rumble after a big hit), then slows near zero (smooth fadeout vs a hard stop). squirrel eiserloh's GDC camera shake talk is the source on this, it's what finally convinced me the linear version always feels subtly wrong no matter how you tune the rate.

One thing I swapped in my flash implementation: instead of tweening modulate on a full-screen ColorRect, I use a minimal shader on a CanvasLayer with blend_mode = ADD. Additive blending means the flash can't fully occlude the scene regardless of alpha. You get a brightness spike rather than an opaque overlay, which reads much closer to how hit flashes feel in commercial games.

The shader is almost nothing:

uniform vec4 flash_color : source_color = vec4(1.0, 1.0, 1.0, 0.0);

void fragment() {
    COLOR = flash_color;
}

Tween flash_color.a from peak intensity down to 0. No need to carefully clamp max alpha, and the result is noticeably better, especially for white flashes. Those go from blinding-white-screen to a hot overexposed look that actually sells the hit.

One extension I added to a similar system: a persistent trauma float that accumulates rather than firing a single impulse. Each hit adds to trauma (capped at 1.0), and shake magnitude is trauma * trauma so it decays non-linearly. I drain it by a fixed amount each process frame. Rapid hits feel progressively worse instead of just repeating the same shake, so the damage actually feels like it's stacking up.

Is your camera impulse using noise-based displacement or just a lerp back to zero?

The one-call convenience is great, but I ended up breaking mine into optional flags. Hit stop almost always fires, but there are cases where you want a camera impulse without a flash (heavy landing, dropping from height) or a flash with no camera movement (a status effect tick on a UI element bleeding into gameplay). Bundling all three unconditionally made those edge cases awkward to work around.

Still a single autoload call, something like GameFeel.impact(intensity, camera: true, flash: false). Same ergonomics at the call site, way more flexibility in practice.

Replying to EmberDusk: trauma accumulation is the right call. one thing that made a big difference in h...
squaring the trauma before using it as shake magnitude is the other piece of this. even with linear decay on the value itself, mapping through shake_magnitude = trauma * trauma means the tail of the shake barely registers visually. 0.3 vs 0.1 trauma look almost identical on screen, while 0.9 vs 0.7 feels massive. combine that with your nonlinear decay and the whole thing just feels right in a way that's hard to explain analytically. took me embarrassingly long to realize this was just applying an output curve, not changing the underlying simulation.
Replying to AuroraGale: +1 on FastNoiseLite over randf(). one thing that tripped me up early: the defaul...

separate noise instances for X and Y are key too. if you sample both axes from the same FastNoiseLite at (time, 0.0) and (time, 1.0) you get correlated movement and the shake reads as this weird consistent diagonal instead of actual chaotic trauma. two noise objects with different seeds, axes move independently, and it immediately reads as impact rather than floaty drift.

costs you literally one extra noise object. no reason not to.

 screen shake chaos game effect
Moonjump
Forum Search Shader Sandbox
Sign In Register