Game Feel with DOTween

"Game feel" (or "juice") is the small motion that makes a game feel alive: coins that pop, buttons that squish, screens that shake. In this tutorial you'll add six of these to a game using DOTween, each in a few lines of code.

You'll need: DOTween installed and set up (see DOTween setup), and using DG.Tweening; at the top of each script.

A tween is like a moving walkway at an airport. You tell it where to start, where to end and how long the trip takes, and it carries the value along smoothly. Easing is how the walkway speeds up and slows down. Linear is a constant speed; OutBack goes a little past the end and settles back, like a spring.

1. A floating, spinning pickup

Two tweens that loop forever. No Update method needed.

using DG.Tweening;
using UnityEngine;

public class PickupIdle : MonoBehaviour
{
    void Start()
    {
        // Bob up 0.3 units and back down, forever.
        transform.DOMoveY(0.3f, 1f)
            .SetRelative()                // "up by 0.3", not "to y = 0.3"
            .SetEase(Ease.InOutSine)
            .SetLoops(-1, LoopType.Yoyo)  // -1 = infinite, Yoyo = back and forth
            .SetLink(gameObject);         // auto-kill when this object is destroyed

        // Spin 360 degrees every 2 seconds, forever.
        transform.DORotate(new Vector3(0f, 360f, 0f), 2f, RotateMode.FastBeyond360)
            .SetRelative()
            .SetEase(Ease.Linear)
            .SetLoops(-1, LoopType.Restart)
            .SetLink(gameObject);
    }
}

What happens at runtime

  1. DOMoveY creates a tween and registers it with DOTween's manager. The settings chained after it are applied before it starts on the next frame.
  2. Each frame, the manager works out how far through the 1-second trip we are, runs it through the ease curve, and sets transform.position.y.
  3. At the end, Yoyo reverses the direction and it starts again. It never completes, so it never gets killed, until the object is destroyed and SetLink kills it.

2. Coin collect: pop, then fly to the UI counter

A Sequence runs tweens one after another, like a short timeline.

using DG.Tweening;
using UnityEngine;

public class Coin : MonoBehaviour
{
    [SerializeField] private int value = 1;
    private bool collected;

    void OnTriggerEnter(Collider other)
    {
        if (collected || !other.CompareTag("Player")) return;
        collected = true;
        GetComponent<Collider>().enabled = false;
        transform.DOKill(); // stop the idle bobbing from part 1

        Sequence s = DOTween.Sequence();
        s.Append(transform.DOScale(1.5f, 0.1f))                    // quick grow
         .Append(transform.DOMoveY(1.5f, 0.3f).SetRelative().SetEase(Ease.OutQuad)) // jump up
         .Join(transform.DOScale(0f, 0.3f).SetEase(Ease.InBack))  // shrink at the same time
         .OnComplete(() =>
         {
             CoinCounter.Instance.Add(value);
             Destroy(gameObject);
         })
         .SetLink(gameObject);
    }
}

Append = "after the previous one". Join = "at the same time as the previous one". So the timeline is: grow (0.1 s), then jump up and shrink together (0.3 s), then add the coin and destroy.

3. A counter that punches when it changes

using DG.Tweening;
using TMPro;
using UnityEngine;

public class CoinCounter : MonoBehaviour
{
    public static CoinCounter Instance { get; private set; }

    [SerializeField] private TMP_Text label;
    private int coins;
    private float shown; // the number currently displayed (animates toward coins)

    void Awake() { Instance = this; }

    public void Add(int amount)
    {
        coins += amount;

        // Count the displayed number up smoothly.
        DOTween.To(() => shown, x => shown = x, coins, 0.4f)
               .OnUpdate(() => label.text = Mathf.RoundToInt(shown).ToString());

        // Punch the scale. Complete() any previous punch first so they don't stack.
        label.transform.DOComplete();
        label.transform.DOPunchScale(Vector3.one * 0.3f, 0.25f, 8, 0.5f);
    }
}
Why DOComplete first?Collect five coins quickly and you'd start five punch tweens on the same scale. They fight, and the label can end up permanently too big. DOComplete() jumps any running tween on that target to its end (the original scale for a punch) before starting the new one.

4. UI buttons that squish

using DG.Tweening;
using UnityEngine;
using UnityEngine.EventSystems;

// Add to any UI Button.
public class ButtonJuice : MonoBehaviour,
    IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerUpHandler
{
    [SerializeField] private float hoverScale = 1.08f;
    [SerializeField] private float pressScale = 0.92f;

    public void OnPointerEnter(PointerEventData e) => ScaleTo(hoverScale);
    public void OnPointerExit(PointerEventData e)  => ScaleTo(1f);
    public void OnPointerDown(PointerEventData e)  => ScaleTo(pressScale);
    public void OnPointerUp(PointerEventData e)    => ScaleTo(hoverScale);

    private void ScaleTo(float s)
    {
        transform.DOKill();
        transform.DOScale(s, 0.12f)
                 .SetEase(Ease.OutBack)
                 .SetUpdate(true); // keep working in the pause menu (timeScale = 0)
    }

    void OnDisable() { transform.DOKill(); transform.localScale = Vector3.one; }
}

The IPointer...Handler interfaces are part of Unity's EventSystem: implement them on a UI object and Unity calls those methods on hover and click.

5. A menu panel that slides and fades in

Give the panel a CanvasGroup component (fades the panel and all its children together).

using DG.Tweening;
using UnityEngine;

[RequireComponent(typeof(CanvasGroup))]
public class SlidePanel : MonoBehaviour
{
    [SerializeField] private float offsetY = -60f;
    [SerializeField] private float duration = 0.3f;

    private RectTransform rect;
    private CanvasGroup group;
    private Vector2 shownPos;
    private Sequence anim;

    void Awake()
    {
        rect = (RectTransform)transform;
        group = GetComponent<CanvasGroup>();
        shownPos = rect.anchoredPosition;

        // Build the animation once and reuse it: forward = show, backwards = hide.
        anim = DOTween.Sequence()
            .Append(rect.DOAnchorPos(shownPos + new Vector2(0f, offsetY), duration).From().SetEase(Ease.OutCubic))
            .Join(group.DOFade(0f, duration).From())
            .SetAutoKill(false) // keep it so we can replay it
            .SetUpdate(true)
            .SetLink(gameObject)
            .Pause();

        group.alpha = 0f;
        group.interactable = group.blocksRaycasts = false;
    }

    public void Show()
    {
        group.interactable = group.blocksRaycasts = true;
        anim.PlayForward();
    }

    public void Hide()
    {
        group.interactable = group.blocksRaycasts = false;
        anim.PlayBackwards();
    }
}

From() flips a tween around: instead of going to the value, it starts at the value and returns to where the object currently is. So "From 60 below and from alpha 0" means "slide up into place while fading in".

6. Hit feedback: flash, knockback, camera shake

using DG.Tweening;
using UnityEngine;

public class HitFeedback : MonoBehaviour
{
    [SerializeField] private Renderer rend;
    [SerializeField] private Color flashColor = Color.white;
    private Color baseColor;

    void Awake() { baseColor = rend.material.color; }

    // Call this when the object takes damage, e.g. from Health.onDamaged.
    public void Play(Vector3 hitDirection)
    {
        // 1. Flash white, then back.
        rend.material.DOKill();
        rend.material.color = flashColor;
        rend.material.DOColor(baseColor, 0.15f);

        // 2. Small knockback, then return.
        transform.DOComplete();
        transform.DOPunchPosition(hitDirection.normalized * 0.3f, 0.2f, 5, 0.5f);

        // 3. Shake the camera.
        Camera.main.transform.DOComplete();
        Camera.main.transform.DOShakePosition(0.2f, 0.15f, 20);
    }
}
Shaking a camera that follows the playerIf a script sets the camera's position every LateUpdate, it will overwrite the shake. Put the camera inside an empty parent object: the follow script moves the parent, and the shake moves the camera's local position (DOShakePosition on a child works in local space relative to the parent).

Bonus: waiting for a tween in a coroutine

IEnumerator OpenChest()
{
    yield return lid.DOLocalRotate(new Vector3(-110f, 0f, 0f), 0.6f)
                    .SetEase(Ease.OutBack)
                    .WaitForCompletion();
    SpawnLoot(); // runs after the lid is fully open
}

Good habits with DOTween

See also