Shooter Tutorial – Niagara & UMG Damage Indicators

Create a Small Game from Scratch Series: Endless Standing First-Person Shooter

Last time, we implementen our first enemy. We can move into implementing damage indicators system for the game.

Final Result

  1. Final Result
  2. Theory
  3. Niagara vs UMG
  4. Niagara – Material
  5. Niagara – System
    1. DamageInfo Float 4 Array
    2. UpdateSpawnFromArray
    3. Initialize Particle
    4. Add Velocity
    5. Dynamic Material Parameters
    6. Maintain in Camera Particle Scale
  6. Niagara – Spawning
    1. PlayPlayerDamageNumber (link)
  7. Niagara – Widget Hack
  8. UMG – Damage Number Widget Component
  9. UMG – Damage Numbers Manager
    1. AddNumber (link)
  10. UMG – Damage Numbers Widget
    1. SetupDamage (link)
  11. Conclusion

Theory

There are a couple of ways to create damage indicators:

  • 2D sprites spawned by Niagara
  • 3D meshes spawned by Niagara or different manager
  • UMG widgets created by Widget Components
  • Drawing them directly on the canvas

In this post I will focus on Niagara and UMG because I think you will learn a lot from those two examples.

Niagara vs UMG

First, let’s figure out which approach might be better for our game.

NiagaraUMG
3d or 2d space?3D only. You can use some tricks to maintain particle scale at different distances, but it’s not ideal.3D or 2D. You can set the Widget Component to render in 2D screen space.
Will be visible behind the wall?No. It exists in 3D space.
(but you could change the material to translucent with depth test disabled – then it will be rendered last)
Yes, if rendered in screen space.
Need pooling?No. A single system can handle all damage indicators.Yes. Widget Components are memory-heavy and expensive to create, so pooling is necessary.
Can animate?Yes. Directly in Niagara and in the material. This allows for a lot of visual customization.Yes, but only via UMG animations or with limited material effects like shadows.
Can we pass parameters?Yes, via Niagara arrays. Can be limited if using one system for all indicators. But still you don’t want to pass a lot of data.Yes, with no significant limits.
Better performance?Generally better than Widget Components.Slower, since each indicator is a separate component being rendered.
OthersWe need an numbers atlas texture which will be loaded without any Mipmaps.UMG can use the font we are using for the game so no need for additional textures.

I’d go with Niagara if it could somehow be rendered in 2D. I know it’s doable—there’s even a plugin that enables Niagara UI rendering. I’m confident something like this will become natively supported in Unreal Engine in the future.

For me Niagara is easier to create a good looking animations than in UMG.

That said, I’ll implement both approaches. They can be toggled in the Player Controller PlayPlayerDamageNumber function.

Niagara – Material

I’m using content from the Lyra Example, with some tweaks to the material and system to make them easier to understand and learn from.

When working with Niagara, it renders a sprite, so we need a texture atlas containing numbers.
Then we perform some math to determine which part of the texture (UV space) to display based on two parameters:

  • Number of digits.
  • Actual value we want to render

Here’s the HLSL code I’m using. It’s easier to read and follow than a messy material node graph. It takes the following inputs:

  • InValue,
  • InNumberOfDigits
  • UV

The output is a float2, which represents the corrected UV coordinates.

// Grid info
float TileSizeX = 1.0 / 4.0;
float TileSizeY = 1.0 / 4.0;

// Compute DigitIndex from UV
float DigitIndex = floor(UV.x * InNumberOfDigits);

// Get local UV within a digit cell
float2 LocalUV;
LocalUV.x = frac(UV.x * InNumberOfDigits);
LocalUV.y = UV.y;

// Extract the correct digit from InValue
float power = pow(10.0, InNumberOfDigits - DigitIndex - 1.0);
float digit = fmod(floor(InValue / power), 10.0);

// Find atlas row and column
float col = fmod(digit, 4.0);
float row = floor(digit / 4.0);

// Base tile UV offset
float2 tileUV;
tileUV.x = col * TileSizeX;
tileUV.y = row * TileSizeY;

// Final full UV
float2 resultUV;
resultUV.x = tileUV.x + LocalUV.x * TileSizeX;
resultUV.y = tileUV.y + LocalUV.y * TileSizeY;

return resultUV;

I’ve also added comments to the node graph to make it easier to follow, but honestly, it’s much clearer when written in HLSL.

Doing math in the Material Graph can get complex and hard to manage. While Unreal theoretically compiles graphs more optimally for each platform, using a bit of HLSL is often more readable and maintainable—as long as you keep it efficient.

This HLSL snippet doesn’t use any advanced or expensive operations, so performance shouldn’t be a concern.

How the math works:

  • NumberOfDigits determines how the UV space is split horizontally
  • We extract each digit from the value based on NumberOfDigits
  • Then we calculate the row and column in the texture where that digit is located
  • Finally, we convert the row and column into proper UV coordinates

Also – check how the Texture is sampled. It will not use mipmaps because we are doing math for the UV and we need full precision.

Niagara – System

You can check out NS_DamageNumbers in the project files.

The goal here is to use a single Niagara System to handle all active damage indicators. This approach is highly efficient, since we don’t need to spawn a new system every time damage occurs.

Niagara doesn’t currently support infinite lifetime for systems. So for persistent effects like this, we just set the loop duration to a very large value.

Don’t worry—this is a common and acceptable practice. Niagara isn’t just for particle effects—it can act as a general-purpose system on both CPU and GPU. Some examples of what Niagara can manage:

  • Spawning ocean grid meshes based on camera location
  • Driving custom cloth animations
  • Drawing textures—like hit indicators—directly onto render targets, which is much faster than using CPU-based render targets via Blueprints

DamageInfo Float 4 Array

Our system uses a single user parameter: a Float4 array called DamageInfo. Each element in this array represents one damage event, with the following data:

  • X: Hit location X (not used currently—you could use this for custom offset or direction)
  • Y: the same as X
  • Z: the same as X
  • W: Damage value – if below 0, it’s a critical hit

The logic is simple:

  • If there’s a new entry in the DamageInfo array: Set up Niagara parameters and Spawn a new particle from the system
  • After spawning, remove the last index from the array

UpdateSpawnFromArray

Niagara is extremely powerful, and it’s worth taking the time to learn it properly. In the Emitter Update phase, we’re running a custom module that handles the logic described earlier—processing the DamageInfo array and preparing particles for spawning.

We’ve added a few custom emitter parameters to help transfer data from the array into individual particles:

  • DamageToDrawn (Float) – The actual damage value to display
  • NumberOfDigits (Int) – Used to determine how many digits we need to render
  • WasCritical (Bool) – Indicates whether the hit was critical (e.g., based on negative damage value)

These parameters are calculated in the Emitter Update and are then used during the Particle Spawn phase.

Initialize Particle

In this stage, we’re using the WasCritical parameter to determine the lifetime and color of each particle.

If the hit was critical, the particle will live longer and have a red color.

This allows us to visually distinguish critical hits from normal ones, adding clarity and feedback for the player.

Add Velocity

We’re using two separate add velocity modules to control the particle’s movement:

  • One for moving the particle upward (vertical velocity)
  • Another for moving it horizontally (side-to-side velocity)

This gives us more control over the trajectory and allows for more dynamic and readable movement of the damage indicators.

Dynamic Material Parameters

This is how we pass the damage value and the number of digits to the material:

We use dynamic material parameters to feed these values into the material instance used by the particle. This allows the material to:

  • Display the correct number
  • Adjust based on the digit count (e.g. single vs. triple-digit numbers)

This approach ensures that the material can respond to real-time gameplay data without needing separate materials or textures for each value.

Maintain in Camera Particle Scale

Finally, in the Particle Update stage, we use a module to scale the particle based on its distance from the camera.

This helps keep the damage indicator’s screen-space size consistent, making it more readable regardless of how far the player is from the source.

You could definitely implement a more refined version yourself—with better math to simulate true 2D screen-space behavior.
For example, you might:

  • Adjust particle position to be closer to the camera
  • Project world position to screen space and align particles accordingly

But that’s beyond the scope of this tutorial.

That wraps up all the custom elements in the Niagara System. It’s actually not too complex once broken down. Now, let’s move on to creating the Niagara System in Blueprint (or C++) and passing data into it.

Niagara – Spawning

Since damage indicators are only visible to the local player, the best place to handle spawning is in the Player Controller. We’re not using a custom Niagara Component—just the default one provided by Unreal.

Auto Activate should be disabled, since we’ll be activating the system manually. The System Asset field should be left empty—we’re assigning it at runtime via a soft reference.

💡 Note: By default, Player Controllers are hidden in the world, so any visual component attached to them won’t be visible. To fix this, make sure your Player Controller is set to not hidden in game, so the damage indicators can actually render.

This function is responsible for

  • Activating the Niagara System
  • Setting its world location to the hit location
    • This eliminates the need to use the X, Y, Z components of the Float4 (called Vector4 in Blueprints), since the particle spawns directly at the correct position and uses world space for updates
  • Passing the updated DamageInfo array to Niagara, which includes the damage value and critical hit information

This function is called from the Pawn – inside STGameplayPawn::ApplyDamage—when we actually hit someone.

That means damage indicators are only created when we successfully apply damage, ensuring the effect only triggers on confirmed hits. Please refer to the Damage System post.

Niagara – Widget Hack

đź’ˇ Note: This solution is currently disabled and included for learning purposes only.

In this setup, we’ve added a Widget Component Renderer to the Niagara System. Instead of spawning a sprite, it spawns a Widget Component that displays a User Widget.

While this feature is still experimental, it’s worth exploring. Niagara can spawn components—just like we did with Decals in an earlier tutorial.

To pass data into the widget, we’ve created a custom component called DamageNumberNiagaraWidgetComponent. This component receives data from Niagara, including the damage value and critical hit flag.

From there, the component passes data into the User Widget (W_DamageNumberNiagara).
This is done using the Tick function, which is not optimal and should not be used in production.

Why it’s using Tick? Because Niagara uses component pooling, the BeginPlay event is only called once when the component is first created—not every time it’s reused.
Since there’s no built-in OnSpawn or OnActivate event, we rely on Tick to update the widget every frame.

In fact, Tick is required both in the Component and in the User Widget it holds, for the same reason: there’s no reliable spawn-time hook when using Niagara’s pooled components.

If you were to modify the engine source, you could implement a proper OnSpawn event for Niagara-spawned components.

So, while this technique should not be used in production, it might spark ideas on how to:

  • Push Niagara beyond traditional particle systems
  • Use it as a generalized spawning/management system
  • Or even extend the engine to unlock new possibilities

UMG – Damage Number Widget Component

To display Damage Indicators as UI Widgets, we use the Widget Component renderer.
I’ve created a custom DamageNumberWidgetComponent to store some default values and handle basic setup.

Keep in mind:

  • Widget Components can have collisions by default — these should be disabled.
  • Also disable Tick and any unnecessary features.
  • This component should do nothing but render the User Widget — no logic, no physics, just pure UI.

Keeping the component lightweight ensures better performance, especially when multiple damage indicators are visible on screen.

UMG – Damage Numbers Manager

Just like Niagara uses pooling for spawning components, we also need a pooling system for UI-based damage indicators. We don’t want to create a new Widget Component and User Widget every time a hit occurs—this would be both inefficient and performance-heavy.

If you followed the projectiles tutorial, you should already be familiar with the concept of pooling.

FreePoolDamageNumberWidgetComponent ArrayAn array holding components that are available for reuse.
TakenPoolDamageNumberWidgetComponent ArrayAn array holding components that are currently active (i.e. visible on screen).

This function is responsible for:

  • Retrieving a component from the free pool (or creating one if necessary)
  • Setting up the widget, including:
    • Assigning the damage value
    • Positioning it at the hit location
  • Binding to an event that triggers when the damage number’s animation is complete
    (so the component can be returned to the pool)

This is the event to free the component.

When a widget component is idle (i.e. waiting in the pool), you can set its mobility to Static.
This can be a major performance win, especially when you’re dealing with a large number of pooled damage indicators.

⚠️ I’m not applying this optimization just yet, as I plan to cover it in a separate profiling and optimization post later on.

Still, it’s a good practice to keep in mind when scaling your system.

UMG – Damage Numbers Widget

This is the actual UI widget that displays the damage number. It’s a simple setup containing a Text Block with the selected font and visual style.

And two basic animations:

  • ShowNormal
  • ShowCritical

ou could easily extend this by creating more animations like: NormalLeft, Right, Bottom etc This would allow you to add variation in movement direction, making the indicator feel more dynamic. You could then randomly select one.

Normally I would create custom Slate widget in C++ to drive the animation from there.

In this example, I used Tick to animate the Text Widget procedurally, just to demonstrate that it’s possible.
However, this is not optimal.

It’s not optimal – Widgets should not have Tick enabled. It is included purely for educational purposes.

The widget knows when its animation finishes, and it fires a Completed dispatcher when that happens. You can bind to animation events directly in UMG, making it easy to notify the manager when the widget has finished its job and can be returned to the pool.

This notifies the manager that the widget has finished its job and is ready to be returned to the pool.

This function handles:

  • Calculating random animation values
  • Initializing the widget with the damage amount and critical hit status
  • Preparing the appropriate animation to play

Conclusion

As you can see, displaying damage indicators isn’t that complicated.
I hope you’ve gained some new insights and ideas from this guide.

Next up, we’ll be diving into decals—creating blood splatters on walls and surfaces.
Stay tuned!