# Godot 4 — Tweens & Juice

> **Purpose**: Reference document for Claude Code. Easing selection by context, duration ratios, squash & stretch values, chain patterns, and anti-patterns from shipped games. The marinade — not Tween API basics.

---

## 1. Easing Selection by Context

The wrong easing on the wrong action feels broken. A menu sliding in with TRANS_ELASTIC looks like a bug. A coin collected with TRANS_LINEAR looks dead.

### The Easing Cheat Sheet

| Action | Transition | Ease | Why |
|--------|-----------|------|-----|
| **Pop-in / Spawn** | `TRANS_BACK` | `EASE_OUT` | Overshoots then settles — "I just arrived!" |
| **Collect / Absorb** | `TRANS_BACK` | `EASE_IN` | Sucks inward past target — "pulled in" |
| **Bounce / Land** | `TRANS_BOUNCE` | `EASE_OUT` | Physical bounce at end — weight |
| **Slide / Move** | `TRANS_QUAD` | `EASE_OUT` | Fast start, smooth stop — responsive |
| **Snap / Lock** | `TRANS_EXPO` | `EASE_OUT` | Near-instant start, precise stop — mechanical |
| **Breathe / Pulse** | `TRANS_SINE` | `EASE_IN_OUT` | Symmetric, organic — alive |
| **Emphasis / Scale up** | `TRANS_CUBIC` | `EASE_OUT` | Medium deceleration — draws attention |
| **Fade out / Disappear** | `TRANS_QUAD` | `EASE_IN` | Accelerates away — gets out of the way |
| **Elastic settle** | `TRANS_ELASTIC` | `EASE_OUT` | Wiggles then stops — springy, playful |
| **Dramatic reveal** | `TRANS_QUINT` | `EASE_OUT` | Very fast start, long tail — cinematic |

### The "Feel Test"

If the player says "that feels snappy" → you probably want `TRANS_EXPO` or `TRANS_QUINT` + `EASE_OUT`.
If the player says "that feels bouncy" → `TRANS_BACK` or `TRANS_ELASTIC` + `EASE_OUT`.
If the player says "that feels smooth" → `TRANS_SINE` or `TRANS_QUAD` + `EASE_IN_OUT`.
If the player says "that feels sluggish" → you used `EASE_IN` on a movement. Switch to `EASE_OUT`.

**Rule of thumb**: `EASE_OUT` on 80% of game actions. The player triggers something → it should respond FAST (high initial velocity) then settle (deceleration). `EASE_IN` makes things feel like they're asking permission before moving.

### TRANS_BACK Overshoot Values

The magic number is `1.70158` — the default overshoot constant in the Back easing function. Godot doesn't expose this directly (no `set_overshoot()` on Tween), but understanding it matters for custom curves.

| Overshoot | Constant | Feel | Use case |
|-----------|----------|------|----------|
| **Subtle** | ~1.2 | Barely visible, just enough to feel alive | UI buttons, menu items |
| **Default** | 1.70158 | Standard "pop" — overshoots ~10% | Spawn effects, pop-in |
| **Bouncy** | ~2.5 | Exaggerated, cartoony overshoot ~17% | Collectible pickup, celebration |
| **Slapstick** | ~3.5 | Big overshoot ~25% — almost broken | Comic hit reactions, gag effects |

**When overshoot helps**: spawning, collecting, UI element appearance, emphasis.
**When overshoot looks buggy**: movement/positioning (overshoots into walls), precise alignment (snapping to grid), anything physics-related.

To get custom overshoot in Godot 4, use `tween_method` with a hand-crafted curve:

```gdscript
## Custom back-out with adjustable overshoot
func back_out(t: float, overshoot: float = 1.70158) -> float:
    t -= 1.0
    return t * t * ((overshoot + 1.0) * t + overshoot) + 1.0

func spawn_with_custom_overshoot(node: Node2D, overshoot: float = 2.5) -> void:
    node.scale = Vector2.ZERO
    var tween := create_tween()
    tween.tween_method(
        func(t: float) -> void:
            var v := back_out(t, overshoot)
            node.scale = Vector2.ONE * v,
        0.0, 1.0, 0.2
    )
```

---

## 2. Duration Ratios

### The Asymmetry Rule

Disappear should ALWAYS be faster than appear. Why: the player's brain needs time to register something new (appear), but once they've decided to dismiss it, waiting feels laggy (disappear).

| Action | Duration | Easing | Rationale |
|--------|----------|--------|-----------|
| **Appear / Pop-in** | 0.15–0.2s | `TRANS_BACK`, `EASE_OUT` | Time to register. Too fast = invisible. Too slow = sluggish |
| **Disappear / Fade-out** | 0.1–0.15s | `TRANS_QUAD`, `EASE_IN` | 70% of appear duration. Gets out of the way |
| **Move / Slide** | 0.2–0.3s | `TRANS_QUAD`, `EASE_OUT` | Longer because the eye tracks it. Too fast = teleport |
| **Emphasis / Pulse** | 0.3–0.5s | `TRANS_SINE`, `EASE_IN_OUT` | Needs dwell time to be noticed |
| **Hit reaction** | 0.05–0.08s | `TRANS_QUAD`, `EASE_OUT` | Near-instant — impact = fast. Recovery = separate tween |
| **Recovery from hit** | 0.15–0.25s | `TRANS_ELASTIC`, `EASE_OUT` | Longer than the hit. Wobble back to normal |
| **Menu transition** | 0.2–0.4s | `TRANS_CUBIC`, `EASE_OUT` | Medium — fast enough to not bore, slow enough to read |
| **Screen shake** | 0.1–0.3s | Decaying amplitude | Length = severity. 0.1s = tap, 0.3s = explosion |
| **Hitstop / Freeze** | 0.03–0.1s | N/A (Engine.time_scale) | 2-6 frames at 60fps. Longer = heavier hit |

### Duration by Importance

This is the Jonasson & Purho principle: **frequent events get short tweens, rare events get long tweens**.

| Frequency | Example | Duration | Juice level |
|-----------|---------|----------|-------------|
| **Every frame** | Idle bob, breathing | Continuous loop | Ambient — barely there |
| **Very frequent** (1-3/sec) | Footsteps, projectile fire | 0.05–0.1s | Subtle — just enough to feel |
| **Frequent** (every few sec) | Jump, land, collect coin | 0.1–0.2s | Noticeable — satisfying |
| **Occasional** | Kill enemy, open chest | 0.2–0.4s | Rewarding — multiple effects |
| **Rare** | Level up, boss phase change | 0.4–0.8s | Dramatic — screen-wide |
| **Unique** | Final boss death, ending | 0.8–2.0s | Cinematic — everything pauses |

---

## 3. Squash & Stretch

### Scale Ratios

The golden rule: **preserve visual volume**. If you squash on Y, stretch on X by the inverse. The area stays roughly the same, so the brain reads "deformation" not "shrinking."

| Action | X Scale | Y Scale | Duration | Recovery |
|--------|---------|---------|----------|----------|
| **Jump launch** | 0.8 | 1.3 | 0.08s | 0.15s back to 1.0 |
| **Fall (in air)** | 0.85 | 1.2 | Continuous while falling | — |
| **Land** | 1.3 | 0.7 | 0.06s | 0.2s bounce back |
| **Dash start** | 1.4 | 0.7 | 0.05s | 0.12s |
| **Hit taken** | 1.15 | 0.85 | 0.04s | 0.15s elastic |
| **Wall slide** | 0.85 | 1.1 | Continuous while sliding | — |
| **Bounce (spring pad)** | 0.6 | 1.5 | 0.06s | 0.3s elastic |
| **Death squish** | 1.6 | 0.3 | 0.1s | None — stays squished |

**Celeste's approach**: Madeline stretches vertically on jump (the sprite elongates), squashes on landing (compressed against ground). The hair follows with 1-2 frame delay, creating a secondary motion that amplifies the feeling. Dust particles spawn on land to sell the impact.

**Dead Cells' approach**: enemies squash wider on hit (expanding perpendicular to the attack direction), then spring back. The squash direction communicates WHERE the hit came from.

### Implementation — Squash & Stretch Component

```gdscript
## Attach to a Node2D or Sprite2D parent. Drives scale for squash & stretch.
## Usage: squash_stretch.play_land(), squash_stretch.play_jump(), etc.
class_name SquashStretch
extends Node

@export var target: Node2D  ## Node whose scale to animate. Auto-detects parent.
@export var return_duration: float = 0.15
@export var return_ease: Tween.TransitionType = Tween.TRANS_ELASTIC

var _tween: Tween


func _ready() -> void:
    if not target:
        target = get_parent() as Node2D


func play(squash_scale: Vector2, squash_duration: float = 0.06) -> void:
    if _tween and _tween.is_valid():
        _tween.kill()
    _tween = create_tween()
    _tween.tween_property(target, "scale", squash_scale, squash_duration)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    _tween.tween_property(target, "scale", Vector2.ONE, return_duration)\
        .set_trans(return_ease).set_ease(Tween.EASE_OUT)


func play_jump() -> void:
    play(Vector2(0.8, 1.3), 0.08)


func play_land() -> void:
    play(Vector2(1.3, 0.7), 0.06)


func play_hit() -> void:
    play(Vector2(1.15, 0.85), 0.04)


func play_dash() -> void:
    play(Vector2(1.4, 0.7), 0.05)


func play_bounce() -> void:
    play(Vector2(0.6, 1.5), 0.06)
    return_duration = 0.3  # Extra wobble for bounces


func play_death() -> void:
    if _tween and _tween.is_valid():
        _tween.kill()
    _tween = create_tween()
    _tween.tween_property(target, "scale", Vector2(1.6, 0.3), 0.1)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
```

---

## 4. The "3 Tweens Per Action" Rule

A single property tween looks flat. A character spawns and just scales up? Boring. Three properties moving in concert = alive.

### The Pattern

Every significant game action should tween **at least 3 properties simultaneously**:

1. **Position** — where it goes
2. **Scale** — how big/small it feels
3. **Modulate (alpha or color)** — how visible/tinted it is

Optional 4th: **rotation** — for extra personality.

### Spawn Example (Bad vs Good)

```gdscript
## BAD — flat, lifeless
func spawn_bad(node: Node2D) -> void:
    node.scale = Vector2.ZERO
    var tween := create_tween()
    tween.tween_property(node, "scale", Vector2.ONE, 0.2)


## GOOD — 3 properties, feels alive
func spawn_good(node: Node2D) -> void:
    var target_pos := node.position
    node.position = target_pos + Vector2(0.0, -20.0)  # Start above
    node.scale = Vector2.ZERO
    node.modulate.a = 0.0

    var tween := create_tween().set_parallel(true)
    tween.tween_property(node, "position", target_pos, 0.2)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_property(node, "scale", Vector2.ONE, 0.2)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
    tween.tween_property(node, "modulate:a", 1.0, 0.15)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
```

### Destroy Example

```gdscript
## Destroy — fast, emphatic, then gone
func destroy_juicy(node: Node2D) -> void:
    var tween := create_tween().set_parallel(true)
    # Scale up briefly (emphasis) then shrink to nothing
    tween.tween_property(node, "scale", Vector2.ONE * 1.3, 0.05)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_property(node, "modulate:a", 0.0, 0.12)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.tween_property(node, "position:y", node.position.y - 15.0, 0.12)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)

    # Scale down after the initial pop
    tween.chain().tween_property(node, "scale", Vector2.ZERO, 0.08)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
    tween.tween_callback(node.queue_free)
```

### Collect / Pickup Example

```gdscript
## Collect — fly toward player, scale down, fade
func collect_item(item: Node2D, target_pos: Vector2) -> void:
    var tween := create_tween().set_parallel(true)
    tween.tween_property(item, "global_position", target_pos, 0.3)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
    tween.tween_property(item, "scale", Vector2.ZERO, 0.25)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.tween_property(item, "modulate:a", 0.0, 0.2)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(item.queue_free)
```

---

## 5. Chain Patterns

### Sequential vs Parallel

```gdscript
## SEQUENTIAL (default) — each step waits for the previous
var tween := create_tween()
tween.tween_property(node, "position:x", 100.0, 0.3)  # Step 1
tween.tween_property(node, "position:y", 200.0, 0.3)  # Step 2 (starts after step 1)
tween.tween_callback(func() -> void: print("done"))    # Step 3

## PARALLEL — all at once
var tween := create_tween().set_parallel(true)
tween.tween_property(node, "position", target, 0.3)    # Simultaneous
tween.tween_property(node, "scale", Vector2.ONE, 0.2)  # Simultaneous
tween.tween_property(node, "modulate:a", 1.0, 0.15)    # Simultaneous

## MIXED — parallel block then sequential continuation
var tween := create_tween().set_parallel(true)
tween.tween_property(node, "position", target, 0.3)
tween.tween_property(node, "scale", Vector2.ONE, 0.2)
tween.chain()  # Everything after this is sequential again
tween.tween_property(node, "modulate:a", 0.0, 0.1)     # After both above finish
tween.tween_callback(node.queue_free)
```

### Staggered List Animation

The cascade effect — items appear one after another with a small delay. Sweet spot: **0.03–0.05s** per item. Below 0.03 feels simultaneous. Above 0.08 feels sluggish.

```gdscript
## Stagger items in a VBoxContainer
func stagger_reveal(container: Container, delay_per_item: float = 0.04) -> void:
    for i in container.get_child_count():
        var child := container.get_child(i) as Control
        child.modulate.a = 0.0
        child.position.x -= 30.0
        var original_x := child.position.x + 30.0

        var tween := create_tween().set_parallel(true)
        tween.tween_property(child, "modulate:a", 1.0, 0.15)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)\
            .set_delay(i * delay_per_item)
        tween.tween_property(child, "position:x", original_x, 0.2)\
            .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)\
            .set_delay(i * delay_per_item)


## Stagger disappear (faster, top-to-bottom)
func stagger_dismiss(container: Container, delay_per_item: float = 0.03) -> void:
    for i in container.get_child_count():
        var child := container.get_child(i) as Control
        var tween := create_tween().set_parallel(true)
        tween.tween_property(child, "modulate:a", 0.0, 0.1)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
            .set_delay(i * delay_per_item)
        tween.tween_property(child, "position:x", child.position.x + 30.0, 0.12)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
            .set_delay(i * delay_per_item)
```

**Cap it**: 10-15 items max in a stagger. 50 items at 0.04s = 2 seconds of waiting. If you have more items, stagger the first 8 then reveal the rest instantly.

### Tween Chains for Combat Hit

```gdscript
## Hit reaction: freeze → flash → knockback → recovery
func hit_reaction(target: Node2D, hit_dir: Vector2, damage: int) -> void:
    # 1. Hitstop (freeze frame)
    Engine.time_scale = 0.05
    await get_tree().create_timer(0.06 * (1.0 / 0.05)).timeout  # Real-time wait
    Engine.time_scale = 1.0

    # 2. Flash white
    target.modulate = Color.WHITE * 2.0  # Overbright flash
    var tween := create_tween()
    tween.tween_property(target, "modulate", Color.WHITE, 0.08)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)

    # 3. Knockback + squash (parallel)
    tween.set_parallel(true)
    tween.tween_property(target, "position", target.position + hit_dir * 20.0, 0.1)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_property(target, "scale", Vector2(1.15, 0.85), 0.04)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)

    # 4. Recovery (sequential after parallel block)
    tween.chain()
    tween.tween_property(target, "scale", Vector2.ONE, 0.2)\
        .set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
```

---

## 6. Hitstop & Freeze Frame

### Duration Guide

| Hit Severity | Hitstop Duration | time_scale | Frames at 60fps |
|-------------|-----------------|------------|-----------------|
| **Light** (jab, graze) | 0.03s | 0.05 | 2 frames |
| **Medium** (standard hit) | 0.05–0.06s | 0.02 | 3-4 frames |
| **Heavy** (charged attack) | 0.08–0.1s | 0.01 | 5-6 frames |
| **Critical** (boss damage) | 0.12–0.15s | 0.0 | 7-9 frames |
| **Finisher** (killing blow) | 0.2–0.4s | 0.0 | 12-24 frames |

**Dead Cells** uses short, punchy hitstops (~3-4 frames) on every hit, with longer stops on crits and finishers. The constant micro-freezes make combat feel rhythmic.

**Hades** layers hitstop with camera zoom — on a big hit, the camera snaps closer to Zagreus for 2-3 frames, then eases back. This amplifies the freeze without making it longer.

### Safe Hitstop Implementation

```gdscript
## Hitstop that doesn't break GolemCapture or physics
## NEVER set time_scale below 0.15 for more than 0.15s real-time
class_name HitStop
extends Node

var _tween: Tween


func freeze(duration: float = 0.06, time_scale: float = 0.05) -> void:
    if _tween and _tween.is_valid():
        _tween.kill()
    Engine.time_scale = time_scale
    # Timer waits in REAL time (not game time), so divide by time_scale
    _tween = create_tween().set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
    _tween.tween_callback(func() -> void: Engine.time_scale = 1.0)\
        .set_delay(duration / time_scale)


func freeze_with_zoom(cam: Camera2D, duration: float = 0.08) -> void:
    var original_zoom := cam.zoom
    cam.zoom = original_zoom * 1.1  # Snap closer
    freeze(duration, 0.02)
    await _tween.finished
    var recover := create_tween()
    recover.tween_property(cam, "zoom", original_zoom, 0.15)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
```

---

## 7. Juice Toolkit — Complete Script

Drop-in utility for common juice effects. Attach as autoload or call from anywhere.

```gdscript
## Juice toolkit — drop-in utility for common game feel effects.
## Usage: Juice.pop(sprite), Juice.shake(camera), Juice.flash(sprite)
class_name Juice
extends Node


## Pop-in from zero with overshoot
static func pop(node: Node2D, duration: float = 0.2) -> Tween:
    node.scale = Vector2.ZERO
    var tween := node.create_tween()
    tween.tween_property(node, "scale", Vector2.ONE, duration)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
    return tween


## Pop-in with offset (falls from above)
static func pop_from(node: Node2D, offset: Vector2 = Vector2(0, -20), duration: float = 0.2) -> Tween:
    var target_pos := node.position
    node.position = target_pos + offset
    node.scale = Vector2.ZERO
    node.modulate.a = 0.0
    var tween := node.create_tween().set_parallel(true)
    tween.tween_property(node, "position", target_pos, duration)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_property(node, "scale", Vector2.ONE, duration)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
    tween.tween_property(node, "modulate:a", 1.0, duration * 0.7)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    return tween


## Shrink-and-fade destroy
static func poof(node: Node2D, duration: float = 0.15) -> Tween:
    var tween := node.create_tween().set_parallel(true)
    tween.tween_property(node, "scale", Vector2.ONE * 1.3, duration * 0.3)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_property(node, "modulate:a", 0.0, duration)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(node.queue_free)
    return tween


## White flash (hit feedback)
static func flash(node: CanvasItem, duration: float = 0.08) -> Tween:
    var original := node.modulate
    node.modulate = Color(2.0, 2.0, 2.0)  # Overbright, capped to avoid HDR blowout
    var tween := node.create_tween()
    tween.tween_property(node, "modulate", original, duration)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    return tween


## Camera shake with decaying amplitude
static func shake(cam: Camera2D, intensity: float = 5.0, duration: float = 0.2) -> Tween:
    var original_offset := cam.offset
    var tween := cam.create_tween()
    var steps := int(duration / 0.02)  # One shake every 20ms
    for i in steps:
        var decay := 1.0 - (float(i) / float(steps))
        var offset := Vector2(
            randf_range(-intensity, intensity) * decay,
            randf_range(-intensity, intensity) * decay
        )
        tween.tween_property(cam, "offset", original_offset + offset, 0.02)
    tween.tween_property(cam, "offset", original_offset, 0.02)
    return tween


## Gentle float/bob loop (idle animation)
static func bob(node: Node2D, amplitude: float = 3.0, speed: float = 2.0) -> Tween:
    var base_y := node.position.y
    var tween := node.create_tween().set_loops()
    tween.tween_property(node, "position:y", base_y - amplitude, speed / 2.0)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(node, "position:y", base_y + amplitude, speed / 2.0)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
    return tween


## Pulse scale (draw attention to something)
static func pulse(node: Node2D, target_scale: float = 1.15, duration: float = 0.4) -> Tween:
    var tween := node.create_tween().set_loops()
    tween.tween_property(node, "scale", Vector2.ONE * target_scale, duration / 2.0)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(node, "scale", Vector2.ONE, duration / 2.0)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
    return tween


## Fly-to-target collect animation
static func collect(item: Node2D, target_pos: Vector2, duration: float = 0.3) -> Tween:
    var tween := item.create_tween().set_parallel(true)
    tween.tween_property(item, "global_position", target_pos, duration)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
    tween.tween_property(item, "scale", Vector2.ZERO, duration * 0.85)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.tween_property(item, "modulate:a", 0.0, duration * 0.7)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(item.queue_free)
    return tween


## Number popup (damage, XP, etc.)
static func number_popup(parent: Node, value: String, start_pos: Vector2, color: Color = Color.WHITE) -> void:
    var label := Label.new()
    label.text = value
    label.add_theme_color_override("font_color", color)
    label.position = start_pos
    label.z_index = 100
    parent.add_child(label)

    var tween := label.create_tween().set_parallel(true)
    tween.tween_property(label, "position:y", start_pos.y - 40.0, 0.5)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
    tween.tween_property(label, "modulate:a", 0.0, 0.5)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
        .set_delay(0.3)
    tween.tween_property(label, "scale", Vector2.ONE * 0.5, 0.5)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
        .set_delay(0.2)
    tween.chain().tween_callback(label.queue_free)
```

---

## 8. Anti-Patterns

### Tween Fighting

Two tweens animating the same property on the same node = undefined jittering. The second tween doesn't cancel the first — they fight every frame.

```gdscript
## BAD — tween fighting
func on_mouse_enter() -> void:
    create_tween().tween_property(self, "scale", Vector2.ONE * 1.1, 0.2)

func on_mouse_exit() -> void:
    # THIS FIGHTS the enter tween if called before it finishes
    create_tween().tween_property(self, "scale", Vector2.ONE, 0.2)


## GOOD — kill the previous tween first
var _hover_tween: Tween

func on_mouse_enter() -> void:
    if _hover_tween and _hover_tween.is_valid():
        _hover_tween.kill()
    _hover_tween = create_tween()
    _hover_tween.tween_property(self, "scale", Vector2.ONE * 1.1, 0.2)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

func on_mouse_exit() -> void:
    if _hover_tween and _hover_tween.is_valid():
        _hover_tween.kill()
    _hover_tween = create_tween()
    _hover_tween.tween_property(self, "scale", Vector2.ONE, 0.15)\
        .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
```

### Tween Accumulation

Creating tweens in `_process()` or rapid-fire signals without killing old ones = memory leak + visual chaos.

```gdscript
## BAD — accumulates tweens every frame
func _process(_delta: float) -> void:
    create_tween().tween_property($Sprite, "rotation", target_rot, 0.5)

## GOOD — single tween, re-targeted
var _rotation_tween: Tween

func update_rotation(target_rot: float) -> void:
    if _rotation_tween and _rotation_tween.is_valid():
        _rotation_tween.kill()
    _rotation_tween = create_tween()
    _rotation_tween.tween_property($Sprite, "rotation", target_rot, 0.5)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
```

### Over-Juicing

When every single action has max juice, nothing feels special. The screen shakes on every step, every coin spawns with elastic bounce, every button has a wobble — the player gets sensory fatigue.

**The Dead Cells rule**: regular attacks get 2-3 frame hitstop + small shake. Critical hits get 5-6 frame hitstop + big shake + zoom. If everything is critical, nothing is.

| Trap | Symptom | Fix |
|------|---------|-----|
| **Equal juice everywhere** | Nothing stands out, player is numb | Reserve big effects for rare/important events |
| **Screen shake addiction** | Player feels nauseous, loses spatial awareness | Max 2-3 shakes per second. Shake amplitude < 8px for frequent events |
| **Elastic on everything** | Wobbly jelly world, looks unintentional | Elastic only for bouncy/playful contexts. Quad/Cubic for serious |
| **Slow tweens on frequent actions** | Game feels sluggish despite "looking juicy" | Frequent = fast (0.05-0.1s). Slow tweens on rare events only |
| **Flash on every hit** | Strobe effect, accessibility disaster | Flash once per hit max. No flash on environmental damage |

### Tween in `_ready()` Before Node Is In Tree

```gdscript
## BAD — tween created before node has a SceneTree
func _ready() -> void:
    # If this node was just created via .new() and not yet add_child'd,
    # create_tween() returns null or crashes
    create_tween().tween_property(self, "scale", Vector2.ONE, 0.2)

## GOOD — defer or use the tree
func _ready() -> void:
    await get_tree().process_frame
    var tween := create_tween()
    tween.tween_property(self, "scale", Vector2.ONE, 0.2)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
```

---

## 9. Combining Techniques (Stack Examples)

### Stack: "Enemy Spawn"

Timing: 0.25s total. The enemy materializes with presence.

1. Scale: `Vector2.ZERO` → `Vector2.ONE` over 0.2s, `TRANS_BACK` `EASE_OUT`
2. Position: start 15px above, drop to final position over 0.2s, `TRANS_QUAD` `EASE_OUT`
3. Alpha: 0.0 → 1.0 over 0.12s (faster than scale — visible before fully sized)
4. Shadow: separate sprite, scale X from 0.3 to 1.0 synced with position Y
5. SFX: "whoosh" at t=0.0, "thud" at t=0.15 (land)
6. Particles: dust burst at land position, 4-6 particles, 0.3s lifetime

### Stack: "Player Takes Damage"

Timing: ~0.4s total cascade.

1. **t=0.00**: Hitstop — `Engine.time_scale = 0.02`, hold 0.06s real-time
2. **t=0.06**: Flash — `modulate = Color(2.0, 2.0, 2.0)`, recover over 0.08s
3. **t=0.06**: Knockback — position += hit_dir * 20px over 0.1s, `TRANS_QUAD` `EASE_OUT`
4. **t=0.06**: Squash — scale `Vector2(1.15, 0.85)` over 0.04s, elastic recover 0.2s
5. **t=0.06**: Screen shake — intensity 4px, duration 0.15s
6. **t=0.06**: Particles — 3-5 hit sparks in hit_dir, 0.2s lifetime
7. **t=0.10**: Camera — optional micro-zoom 1.05x, recover 0.2s
8. **t=0.06**: SFX — impact sound. Health SFX if low.

### Stack: "Level Complete / Victory"

Timing: 1.5s total. Everything celebrates.

1. **t=0.00**: Hitstop — 0.15s full freeze (Engine.time_scale = 0.0)
2. **t=0.15**: Player scale pulse — `Vector2.ONE * 1.3` over 0.2s, elastic recover
3. **t=0.15**: Screen flash — white ColorRect alpha 0.0 → 0.3 → 0.0 over 0.3s
4. **t=0.25**: Camera zoom — current → 0.9x (pull out) over 0.5s, `TRANS_QUAD`
5. **t=0.30**: Particles — burst from player, 20-30 particles, upward, gold color
6. **t=0.30**: UI stagger — score elements cascade in, 0.04s per item
7. **t=0.50**: SFX — victory jingle. Delayed 0.5s from trigger for buildup

---

## Gotchas

1. **`create_tween()` returns null outside tree** — if the node isn't in the SceneTree yet (just created, not add_child'd), `create_tween()` fails silently or crashes. Always defer or use `get_tree().create_tween()` from a node that IS in the tree
2. **Tweens are not re-usable** — calling methods on a finished Tween = undefined behavior. Always create a new one. Store the reference only to `kill()` it before creating a replacement
3. **`set_parallel()` applies to ALL subsequent tweeners** — it's a mode switch, not per-tweener. Use `chain()` to exit parallel mode. Forgetting `chain()` means your "sequential" steps run simultaneously
4. **`TRANS_ELASTIC` overshoots negative** — a scale tween from 0 to 1 with `TRANS_ELASTIC` will briefly go negative (inverted sprite). For scale tweens, `TRANS_BACK` is usually safer
5. **Hitstop via `Engine.time_scale` affects EVERYTHING** — timers, tweens, physics, particles. Tweens created with `set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)` ignore time_scale but then ignore pause. There's no perfect solution — accept the tradeoff
6. **GolemCapture breaks below time_scale 0.15** — if using MCP screenshots during gameplay, never freeze longer than 0.15s real-time, or use time_scale >= 0.15
7. **Modulate `Color.WHITE * 2.0` + HDR = overbright blowout** — cap flash color at `Color(2.0, 2.0, 2.0)` max. Higher values produce bloom artifacts in Forward+ renderer
8. **Squash & stretch on CollisionShape** — scaling the visual sprite doesn't scale the collider. This is correct behavior (collision stays accurate), but looks wrong if the collider is visible in debug. Scale only the Sprite2D, not the parent
9. **Stagger on 50+ items = 2+ second wait** — cap stagger lists at 10-15 items, or stagger first batch and instant-show the rest
10. **`tween_property` with wrong path = silent fail** — `"position"` works, `"Position"` doesn't. GDScript property names are snake_case. No error, just no animation
