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
DOMoveYcreates a tween and registers it with DOTween's manager. The settings chained after it are applied before it starts on the next frame.- 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. - 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
SetLinkkills 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);
}
}
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);
}
}
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
- Always
SetLink(gameObject)(orDOKill()inOnDestroy) for tweens on objects that can be destroyed. - Kill or complete before restarting a tween on the same property.
SetUpdate(true)for anything in menus that must animate while the game is paused.- Don't create tweens in
Update. Create them when something happens. - Reuse tweens you play often with
SetAutoKill(false)+PlayForward/PlayBackwards/Restart, like the panel above. - Keep durations short. Most UI and feedback tweens feel best between 0.1 and 0.4 seconds.
See also
- DOTween reference
- Animator (for character animation, use the Animator; DOTween is for code-driven motion)
This page is a simplified, AI-assisted interpretation. For the complete and authoritative reference, see the official DOTween documentation.