DEV Community

saltmire
saltmire

Posted on • Originally published at saltmire.github.io

6 Godot 4 game feel tricks (each just a few lines of GDScript)

"Game feel" is the difference between a hit that just subtracts HP and a hit that
lands. In Godot 4 almost none of it needs plugins — it's small, cheap touches
stacked on top of each other. Here are six you can paste in today, each only a few
lines.

1. Squash and stretch on impact

Nothing sells a hit like a quick scale pop. Punch the scale up, then let it spring
back with an elastic transition:

func pop(node: Node2D, amount := 0.25, duration := 0.25) -> void:
    var base := node.scale
    var t := create_tween()
    node.scale = base * (1.0 + amount)
    t.tween_property(node, "scale", base, duration) \
        .set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
Enter fullscreen mode Exit fullscreen mode

Call pop($Sprite2D) on landing, on pickup, on button press — anywhere you want a
tiny "yes".

2. Sprite flash on hit

A single white frame reads instantly as "that connected". Drive a flash_amount
uniform on a tiny shader and tween it back to zero:

// flash.gdshader
shader_type canvas_item;
uniform float flash_amount : hint_range(0.0, 1.0) = 0.0;
void fragment() {
    vec4 c = texture(TEXTURE, UV);
    COLOR = vec4(mix(c.rgb, vec3(1.0), flash_amount), c.a);
}
Enter fullscreen mode Exit fullscreen mode
func flash(sprite: CanvasItem, duration := 0.12) -> void:
    var mat: ShaderMaterial = sprite.material
    mat.set_shader_parameter("flash_amount", 1.0)
    create_tween().tween_method(
        func(v): mat.set_shader_parameter("flash_amount", v), 1.0, 0.0, duration)
Enter fullscreen mode Exit fullscreen mode

3. Pooled damage numbers

Floating numbers give weight to every hit — but instancing a Label per hit thrashes
the GC. Spawn from a small pool instead and tween position + fade:

func damage_number(pos: Vector2, value: int) -> void:
    var label := _pool_get()          # reuse a Label from a preallocated array
    label.text = str(value)
    label.global_position = pos
    label.modulate.a = 1.0
    var t := create_tween().set_parallel()
    t.tween_property(label, "global_position", pos + Vector2(0, -32), 0.5)
    t.tween_property(label, "modulate:a", 0.0, 0.5)
    t.chain().tween_callback(func(): _pool_return(label))
Enter fullscreen mode Exit fullscreen mode

The pool is just an array you fill on _ready() and hand out round-robin — no
instantiate() on the hot path.

4. Knockback that eases out

Instant teleport-back feels cheap; a short eased slide feels physical:

func knockback(body: Node2D, dir: Vector2, strength := 180.0, duration := 0.15) -> void:
    var target := body.global_position + dir.normalized() * strength
    create_tween().tween_property(body, "global_position", target, duration) \
        .set_trans(Tween.TRANS_QUART).set_ease(Tween.EASE_OUT)
Enter fullscreen mode Exit fullscreen mode

For a CharacterBody2D you'd feed this into velocity instead, but the easing idea
is the same: fast start, soft stop.

5. Slow-motion on the big hit

Reserve time dilation for moments that earn it — a finisher, a boss stagger. Drop
Engine.time_scale, wait in real time (unscaled), then ramp back:

func slowmo(scale := 0.05, real_seconds := 0.15) -> void:
    Engine.time_scale = scale
    var timer := get_tree().create_timer(real_seconds, true, false, true) # ignore_time_scale
    await timer.timeout
    create_tween().tween_method(
        func(v): Engine.time_scale = v, scale, 1.0, 0.2)
Enter fullscreen mode Exit fullscreen mode

The true at the end of create_timer is what makes it ignore time_scale — miss
that and your slow-mo waits in slow-mo and never ends.

6. Camera punch

A quick directional shove on the camera, snapping back, adds impact without a full
shake routine:

func punch(cam: Camera2D, dir: Vector2, amount := 8.0, duration := 0.12) -> void:
    var base := cam.offset
    cam.offset = base + dir.normalized() * amount
    create_tween().tween_property(cam, "offset", base, duration) \
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
Enter fullscreen mode Exit fullscreen mode

Point dir opposite the hit — punch the camera away from the impact and the brain
fills in the force.

Stacking them

The magic is in combining: on a real hit you might flash() the target, pop() it,
spawn a damage_number(), knockback() it, and punch() the camera — all in the
same frame. Individually each is a nudge; together they're the difference between
flat and juicy. The only hard part is wiring them into one clean call and keeping the
tweens from stepping on each other, which is exactly the boilerplate Saltmire Impact
folds into a single line.


If you'd rather drop this in than build it, Saltmire Impact does it as a ready-made tool: https://saltmire.itch.io/saltmire-impact

Originally published at https://saltmire.github.io/godot-4-game-feel-tricks.html

Top comments (0)