Tweens, Shake, and Feedback

Module 6 ยท Lesson 1 ยท ~30 min ยท Godot 4.x

The gap between "working prototype" and "looks professional" is almost entirely juice: the tiny animations, screen shakes, pulses, and sounds that sell every interaction. Juice is cheap โ€” a well-applied screen shake is 10 lines and transforms a game. This lesson is a catalog.

The juice checklist

Go through your game and make sure every meaningful action has feedback:

None of these individually matters. Together they make your game feel expensive.

Screen shake

# scripts/shake.gd โ€” attach to your Camera2D or a root CanvasLayer
extends Node

@onready var camera: Camera2D = get_parent()
var shake_strength: float = 0.0
const DECAY: float = 5.0

func _process(delta: float) -> void:
    if shake_strength > 0.01:
        camera.offset = Vector2(
            randf_range(-shake_strength, shake_strength),
            randf_range(-shake_strength, shake_strength)
        )
        shake_strength = move_toward(shake_strength, 0, DECAY * delta)
    else:
        camera.offset = Vector2.ZERO

func shake(strength: float) -> void:
    shake_strength = max(shake_strength, strength)

Call shake(8) on a normal hit, shake(20) on a crit. Don't exceed ~25 โ€” more feels like a bug.

If your game uses a CanvasLayer for UI (which it does), use a different approach for UI shake โ€” tween the layer's offset:

func shake_ui(strength: float = 8.0, duration: float = 0.15) -> void:
    var layer: CanvasLayer = $UI
    var t := create_tween()
    var start := layer.offset
    for i in 6:
        t.tween_property(layer, "offset", start + Vector2(randf_range(-strength, strength), randf_range(-strength, strength)), duration / 6)
    t.tween_property(layer, "offset", start, 0.05)

Flash and pulse

When an entity takes damage, tint it briefly:

func flash_damage() -> void:
    var t := create_tween()
    t.tween_property(self, "modulate", Color(2.0, 0.5, 0.5), 0.05)
    t.tween_property(self, "modulate", Color.WHITE, 0.15)

Pulsing for emphasis (low HP warning):

func start_low_hp_pulse() -> void:
    var t := create_tween().set_loops()
    t.tween_property($HPLabel, "modulate", Color(1.5, 0.5, 0.5), 0.5)
    t.tween_property($HPLabel, "modulate", Color.WHITE, 0.5)

Kill the tween when HP goes above the threshold.

The 12 principles (abbreviated)

Disney's animation principles translate directly to game feel:

Read Martin Stig Andersen's "Juice It or Lose It" talk if you want a full treatment. For Lexicon Duel, just apply each of the above to your hit and play animations.

Transitions between screens

Jumping cuts feel cheap. A half-second crossfade on scene changes feels expensive.

# autoload SceneTransition.gd
extends CanvasLayer

@onready var rect: ColorRect = $Fader

func _ready() -> void:
    rect.color = Color.BLACK
    rect.modulate.a = 0.0

func fade_to_scene(path: String) -> void:
    var t := create_tween()
    t.tween_property(rect, "modulate:a", 1.0, 0.3)
    t.tween_callback(func(): get_tree().change_scene_to_file(path))
    t.tween_property(rect, "modulate:a", 0.0, 0.3)

Save as an autoload. Call SceneTransition.fade_to_scene("res://scenes/map.tscn") anywhere.

Haptic feedback on mobile

From Module 2, briefly: Input.vibrate_handheld(ms). Use sparingly โ€” every card tap is annoying. Reserve for:

Don't over-juice

Warning Balatro juices every interaction to the hilt and it works because the core loop is punchy. In a slower turn-based game, too much juice on every tap is exhausting. Ship with ~60% of what you think is enough. Add more only where playtest reveals flat moments.

Do this now

  1. Add a shake.gd autoload. Call Shake.shake(12) on Combatant.damaged.
  2. Add flash_damage() to your Combatant scene. Call it when damaged.
  3. Add a TRANS_BACK tween to card entries into the hand.
  4. Play a duel. Note the difference.