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.
Go through your game and make sure every meaningful action has feedback:
None of these individually matters. Together they make your game feel expensive.
# 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)
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.
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.
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.
From Module 2, briefly: Input.vibrate_handheld(ms). Use sparingly โ every card tap is annoying. Reserve for:
shake.gd autoload. Call Shake.shake(12) on Combatant.damaged.flash_damage() to your Combatant scene. Call it when damaged.