Skip to content

Floating Damage Numbers in Unity with TextMeshPro

A damage number is a short text spawned where the hit landed, pushed a little to one side so numbers do not pile up, then moved up and faded out in under a second. With a few hits a second it also needs a pool: a handful of texts made once and reused, instead of a new object for every hit.

The Text Toolkit demo's Combat Text showcase: numbers pop up over a yellow monster as it fights, red damage, green heals, blue XP and gold coins, each with its own look, while its health bar drops and refills

For numbers in the game world, use a TextMeshPro (the 3D one, not the UI one) as the prefab, with the font size small and the alignment centred. This script keeps a pool of them, shows a number at a position, and moves each one up and out:

using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class DamageNumbers : MonoBehaviour
{
private class Popup
{
public TextMeshPro text;
public Vector3 from;
public float time;
}
[SerializeField] private TextMeshPro prefab;
[SerializeField] private int poolSize = 20;
[SerializeField] private float life = 0.8f;
[SerializeField] private float rise = 1.2f;
[SerializeField] private float spread = 0.4f;
private readonly Queue<TextMeshPro> pool = new Queue<TextMeshPro>();
private readonly List<Popup> active = new List<Popup>();
private Transform cam;
private void Awake()
{
cam = Camera.main.transform;
for (int i = 0; i < poolSize; i++)
{
TextMeshPro text = Instantiate(prefab, transform);
text.gameObject.SetActive(false);
pool.Enqueue(text);
}
}
public void Show(Vector3 position, int amount, bool critical = false)
{
if (pool.Count == 0) Recycle(active[0]);
TextMeshPro text = pool.Dequeue();
text.text = critical ? $"<size=150%>{amount}!</size>" : amount.ToString();
text.color = critical ? new Color(1f, 0.8f, 0.2f) : Color.white;
text.gameObject.SetActive(true);
Vector3 jitter = new Vector3(Random.Range(-spread, spread), Random.Range(0f, spread * 0.5f), 0f);
active.Add(new Popup { text = text, from = position + jitter });
}
private void Update()
{
for (int i = active.Count - 1; i >= 0; i--)
{
Popup p = active[i];
p.time += Time.deltaTime;
float t = p.time / life;
if (t >= 1f) { Recycle(p); continue; }
float eased = 1f - (1f - t) * (1f - t) * (1f - t);
p.text.transform.SetPositionAndRotation(p.from + Vector3.up * rise * eased, cam.rotation);
p.text.alpha = 1f - t * t;
}
}
private void Recycle(Popup p)
{
p.text.gameObject.SetActive(false);
active.Remove(p);
pool.Enqueue(p.text);
}
}

Call damageNumbers.Show(enemy.position + Vector3.up, 42) from wherever damage is dealt. Four details do most of the work:

  • The random offset, so a burst of hits fans out instead of stacking on one spot.
  • An eased rise, fast at first and slowing, which reads as a pop rather than a lift.
  • Facing the camera: copying the camera’s rotation keeps the numbers flat to the screen in 3D.
  • The pool, which reuses the oldest number when every one is in use, so a long fight never creates or destroys an object.

On a canvas instead. For numbers in the UI, use TextMeshPro - Text (UI) under a canvas and move them with RectTransform.anchoredPosition; convert the hit’s world position with Camera.WorldToScreenPoint first. The rest is the same.

  • Behind the enemy. A 3D TextMeshPro sorts like a sprite: raise its renderer’s Sorting Layer or Order in Layer (text.sortingOrder) above the characters’.
  • On a World Space canvas the canvas needs a scale small enough to fit the scene. A new one is sized in pixels, so at a scale of 1 it is hundreds of units wide and its texts are far off screen; 0.01 is a usual start.
  • The value is wrong. Set the text after taking it from the pool, as Show does, not on the prefab before it is made.
  • Numbers flicker as they overlap. Several texts at the same depth sort arbitrarily; give each new one an Order in Layer one higher than the last.

Don’t own it yet? → Get it on the Unity Asset Store

The clip at the top of this page is the demo scene’s Combat Text showcase, number 17 on Demo Scene, exactly as it plays. Open the demo and step to it: everything below is in its Damage object and in Demo/Scripts/Showcases/DemoCombat.cs, ready to take apart and copy.

Every number is a text made in advance. The showcase holds all the texts it will ever show from the start, hidden: four for hits and two for each other kind. Each is a TextMeshPro UI text with the All In 1 Text Shader component and a Typewriter, and each has its own material. Nothing is created or destroyed while it plays: a new hit takes the next text of its kind and plays it again.

Seven kinds, each with its own look and motion. DemoCombat lists them as kinds. A kind holds the texts it can show, its pool, where it spawns, how far it rises and drifts, how long it lives, how big it starts, and what it does to the target:

KindShowsLookTypewriterMotionTarget
Hit-38 to -63Bangers, red: Color Gradient, Extrude, Outline, Outer GlowReveal_PopHit, Hide_Poofrises 120, drifts 30, lives 1 s, starts at 1.5 times its sizeloses a tenth of its health, squashes
Critical-864, -999, -1337Bangers, the same effects with an orange glowReveal_PopCrit, Loop_CritShake, Hide_Poofrises 90, lives 1.5 s, starts at 2.2 timesloses over a third, squashes harder and shakes
CRITICAL!CRITICAL!as the criticalReveal_PopCrit, Hide_Poofspawns with the critical, above it
Heal+95 to +140Lilita One, green: the hit’s effects in greenReveal_PopHit, Loop_HealBob, Hide_Poofrises 100, lives 1.3 sgets back over a third, stretches
MissMISS, DODGE!Lilita One, grey: Color Gradient and Extrude onlyReveal_PopHit, Hide_Poofdrifts 60 to the sidesteps out of the way
XP+25 XP, +40 XPLilita One, blueReveal_PopHit, Hide_Poofrises 140, off to the left
Gold+50 G, +120 GLilita One, gold: Bevel with Specular, Drop Shadow, Outline, Extrude, Color GradientReveal_PopHit, Hide_Poofrises 140, off to the right

Distances are in the canvas’s units. The materials are in Demo/Showcases/Materials, MAT_Demo_Damage_Hit0 to MAT_Demo_Damage_Gold1: the texts of one kind have the same settings, each on its own material.

The pop is the Typewriter. On every pooled text On Start is Nothing, so it waits hidden until it is called. Its three configs do the rest:

  • Reveal: Reveal_PopCrit fades each letter in while it shrinks from three times its size, 23 letters a second; Reveal_PopHit does the same for the smaller numbers.
  • Looped: Loop_CritShake shakes the letters of a critical for as long as it is up; Loop_HealBob bobs a heal up and down.
  • Hide: Hide_Poof fades every letter out while it grows to one and a half times its size.
The Typewriter of one of the demo's critical texts: On Start set to Nothing; Reveal Effects on Reveal_PopCrit, Per Character, 23 characters per second, Fade from 0 to 1 and Scale from 3 to 1; Looped Effects on Loop_CritShake with Shake at speed 7 and length 3; Hide Effects on Hide_Poof, Fade from 1 to 0 and Scale from 1 to 1.5

Spawning one. Spawn takes the kind’s next text in turn, the oldest, gives it one of the kind’s texts with SetText(), then calls Reveal(). Both calls matter on a pooled text: a typewriter stopped by HideImmediate() only arms its reveal on SetText(). It places the text at the spawn point plus the kind’s offset plus a random jitter of up to the kind’s spread to either side, and sets its scale to the kind’s punch.

Every frame. Each live number rises along an ease out curve, 1 - (1 - t)³, so it shoots up and slows; it drifts sideways in a straight line; and its punch scale falls back to normal within a fraction of a second. 0.3 seconds before its life ends it calls Hide(), and once the hide has played it is free for the next hit.

The target. Each kind tells the monster how to react: a hit squashes it, a critical squashes it harder and shakes it from side to side, a heal stretches it, a miss slides it out of the way and back. A silhouette over it flashes white for a fraction of a second, green for a heal. The health bar drops at once, and a second bar behind it drains after it a moment later.

The timing is a list of beats, a kind and a time, over a 6.6 second loop. In a game, the spawn is called from wherever damage is dealt instead.

Copy what fits from the Damage object: its texts with their materials and their Typewriters, one set per kind of number. The code that plays one pooled text is short:

using AllIn1Assets.AllIn1TextShader;
using UnityEngine;
public class ToolkitDamageNumber : MonoBehaviour
{
[SerializeField] private AllIn1TextTypewriter typewriter;
public void Play(int amount, bool critical)
{
typewriter.SetText(critical ? $"{amount}!" : amount.ToString());
typewriter.Reveal();
}
public void End() => typewriter.Hide();
public bool IsDone() => typewriter.IsHidden();
}
This script on one of the demo's pooled critical texts: 128! pops in with the typewriter's reveal, shakes while it holds, and poofs away when End() is called

Move the text up as the demo does, call End() about 0.3 seconds before its life is over, and return it to the pool once IsDone(): it is false while the number shows and true once the hide has played, 0.7 seconds after End() on the demo’s critical. Use the Looped config for anything a kind always does, as the demo’s criticals shake and its heals bob; tags are for one word inside a text (Motion Effects and Tags). The typewriter’s calls are on Scripting.

Particles from the letters, for a big hit, are on Text Particles.

Numbers that share one material batch, as TextMeshPro texts do (since version 1.1), so give every number of a kind the same saved material. A text with a playing typewriter is rewritten every frame, so the cost grows with how many animate at once. Twenty numbers on screen cost nothing worth measuring. See Draw calls and The CPU side.

  • Spawn the numbers for you. The toolkit styles and animates a text; the pool and the spawning are your script, or the demo’s to copy.