Drag, Drop, and Tween Animations

Module 4 ยท Lesson 2 ยท ~40 min ยท Godot 4.x

A card game without physical-feeling cards is a spreadsheet. This lesson covers Godot's drag-drop system and Tween animation โ€” the two pieces you need for cards that fly, snap, and snap back with convincing weight.

Tweens: the swiss army knife of animation

A Tween is an object that interpolates a property between values over time. Create one, chain calls, done.

var t := create_tween()
t.tween_property(card, "position", target_pos, 0.3)  # over 0.3s
t.tween_property(card, "rotation", 0.0, 0.1)
t.parallel().tween_property(card, "scale", Vector2.ONE, 0.15)
t.tween_callback(func(): print("done"))

Key moves:

# A card "wobble" on hover โ€” use this everywhere
func wobble() -> void:
    var t := create_tween()
    t.set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
    t.tween_property(self, "scale", Vector2(1.1, 1.1), 0.15)
    t.tween_property(self, "scale", Vector2.ONE, 0.3)
If you've used GSAP in JS, CSS transitions with cubic-bezier, or Core Animation on iOS, Godot's Tween is the same concept. Slightly less powerful than GSAP, far nicer than manual lerping.

Drag and drop via Control

Godot's Control nodes have built-in drag-drop routing. Three virtual methods:

# In card.gd (extends Control)

func _get_drag_data(_pos: Vector2) -> Variant:
    # Called when drag starts. Return data or null to cancel.
    set_drag_preview(_make_preview())
    return self  # pass self; other nodes receive this in _can_drop_data

func _make_preview() -> Control:
    var ghost := Control.new()
    var rect := ColorRect.new()
    rect.color = Color("fca311")
    rect.size = size
    rect.modulate.a = 0.6
    ghost.add_child(rect)
    return ghost

And on the target (e.g. a "word slot" container):

# In word_slot.gd (extends Control)

func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
    return data is Card

func _drop_data(_pos: Vector2, data: Variant) -> void:
    var card: Card = data
    accept_card(card)

When you hold and drag a Card, Godot:

  1. Calls _get_drag_data on the card (dragged control).
  2. Shows the preview attached to the cursor.
  3. On every Control you hover, calls _can_drop_data. If true, the preview shows "ok".
  4. On release, calls _drop_data on the target.

Handles mouse and touch uniformly, courtesy of the emulation settings from Module 2.

When to use drag-drop vs tap

Lexicon Duel can work two ways:

  1. Tap to select + submit (what we built in Module 3): tap cards to add to the current word, hit Submit. Fast. Accessible. Works with one hand. Recommended for mobile.
  2. Drag to assemble: drag cards into a "word" slot to arrange them in order. More physical. More animation. Better on tablet.

For a mobile-first game with 5 hours/week, ship tap-to-select first. Add drag as a polish pass (or a setting) if you still have steam after the core loop is fun.

Design instinct Physical interactions feel better, but slower. Fast mobile puzzle games (Wordle, Threes!) win on immediacy. Fancy card games (Hearthstone, Slay the Spire) justify drag-drop with dramatic play animations. Match your interaction pace to the feeling you're selling.

Snap-back: the most important trick

When a drag is dropped somewhere invalid, the card should smoothly animate back. Godot's drag system doesn't do this automatically โ€” but it's four lines:

# In card.gd
var _drag_origin: Vector2

func _get_drag_data(_pos: Vector2) -> Variant:
    _drag_origin = global_position
    set_drag_preview(_make_preview())
    return self

# Called by Godot when the drag completes, whether accepted or not.
func _notification(what: int) -> void:
    if what == NOTIFICATION_DRAG_END:
        if not get_viewport().gui_is_drag_successful():
            _snap_back()

func _snap_back() -> void:
    var t := create_tween()
    t.tween_property(self, "global_position", _drag_origin, 0.2).set_trans(Tween.TRANS_BACK)

TRANS_BACK has that delightful overshoot-and-settle curve. Use it here.

Hover lift

Cards should visually respond to hover on desktop (and long-press preview on mobile). Common pattern:

# card.gd
const HOVER_LIFT := Vector2(0, -20)
var _base_pos: Vector2

func _ready() -> void:
    _base_pos = position
    mouse_entered.connect(_on_hover_in)
    mouse_exited.connect(_on_hover_out)

func _on_hover_in() -> void:
    var t := create_tween()
    t.tween_property(self, "position", _base_pos + HOVER_LIFT, 0.12).set_trans(Tween.TRANS_CUBIC)

func _on_hover_out() -> void:
    var t := create_tween()
    t.tween_property(self, "position", _base_pos, 0.12).set_trans(Tween.TRANS_CUBIC)

Subtle but players will feel it. On mobile, replace mouse_entered with a long-press detection โ€” but honestly, don't bother on the first pass. Phones get tap-to-select.

Cancelling tweens

If you trigger a hover tween while another is running, both animate and the card jitters. Always kill prior tweens:

var _current_tween: Tween

func _on_hover_in() -> void:
    if _current_tween: _current_tween.kill()
    _current_tween = create_tween()
    _current_tween.tween_property(self, "position", _base_pos + HOVER_LIFT, 0.12)

This is the #1 cause of "my animations are glitchy" bugs. Just kill the old one.

Do this now

  1. Add hover lift to your Card scene. Connect mouse_entered/exited to tween position.
  2. Add a subtle scale pulse on tap โ€” scale to 1.1 and back over 0.15s.
  3. Feel the difference before and after. This is what players mean when they say a game "feels good."