MultiLoader 1.21+ · Part 22

Custom Particles (MultiLoader 1.21+)

INTERMEDIATE MULTILOADER 1.21-1.21.1 20 min read · Oct 5, 2026
MultiLoader 1.21+ · 28 parts
1 Getting Started with MultiLoader 1.21+ 2 Setting Up RegistrationUtils 3 Creating Items (MultiLoader 1.21+) 4 Creating Blocks (MultiLoader 1.21+) 5 Data Generation: Block & Item Models (MultiLoader 1.21+) 6 Data Generation: Block Loot Tables (MultiLoader 1.21+) 7 Data Generation: Crafting Recipes (MultiLoader 1.21+) 8 Data Generation: Block & Item Tags (MultiLoader 1.21+) 9 Custom Food Items (MultiLoader 1.21+) 10 Custom Tools (MultiLoader 1.21+) 11 Custom Armour (MultiLoader 1.21+) 12 Block Entities (MultiLoader 1.21+) 13 Config Files (MultiLoader 1.21+) 14 Custom Sounds (MultiLoader 1.21+) 15 Events and Listeners (MultiLoader 1.21+) 16 Networking and Custom Packets (MultiLoader 1.21+) 17 Data Generation: Advancements (MultiLoader 1.21+) 18 Data Generation: Language Files (MultiLoader 1.21+) 19 Custom Entities (MultiLoader 1.21+) 20 Ore Generation (MultiLoader 1.21+) 21 Introduction to Mixins (MultiLoader 1.21+) 22 Custom Particles (MultiLoader 1.21+) 23 Menus & Screens (MultiLoader 1.21+) 24 Key Bindings (MultiLoader 1.21+) 25 Custom Potion Effects (MultiLoader 1.21+) 26 Custom Enchantments (MultiLoader 1.21+) 27 Custom Commands (MultiLoader 1.21+) 28 Custom Biomes (MultiLoader 1.21+)

Custom particles have four moving parts: a registered ParticleType in common, a JSON description file that lists the texture sprites, a client-side provider class that translates the type into actual rendered quads, and a client-only registration hook. The server only knows about the type; all visual logic stays on the client.

NOTE
Complete the Setting Up RegistrationUtils tutorial before continuing. Particle types are registered via RegistrationProvider in the common module.

Particle Type Registry

Create a ParticleRegistry class in your common registry package. For a particle that needs no extra data (just a position and velocity), use SimpleParticleType:

java
public class ParticleRegistry {
public static final RegistrationProvider<ParticleType<?>> PARTICLES =
RegistrationProvider.get(Registries.PARTICLE_TYPE, Constants.MOD_ID);
public static final RegistryObject<ParticleType<?>, SimpleParticleType> SPARKLE =
PARTICLES.register("sparkle", () -> new SimpleParticleType(false));
public static void init() {}
}

The boolean argument to SimpleParticleType controls whether the particle always shows regardless of the client's particle setting (true) or respects it (false). Use false for decorative particles so players with reduced particle settings are not affected.

Because the SimpleParticleType constructor is protected, let's add our access transformer and access widener entries:

java
//AT
public net.minecraft.core.particles.SimpleParticleType <init>(Z)V # <init>
// AW
accessible method net/minecraft/core/particles/SimpleParticleType <init> (Z)V

Add ParticleRegistry.init() to CommonClass.init().

Particle Description File

Create the particle description JSON at:

text
src/main/resources/assets/examplemod/particles/sparkle.json
json
{
"textures": [
"examplemod:sparkle_0",
"examplemod:sparkle_1",
"examplemod:sparkle_2"
]
}

Each entry maps to a texture sprite in your textures/particle/ folder. The sprite names correspond to files:

text
src/main/resources/assets/examplemod/textures/particle/sparkle_0.png
src/main/resources/assets/examplemod/textures/particle/sparkle_1.png
src/main/resources/assets/examplemod/textures/particle/sparkle_2.png

Particle textures are 8×8 or 16×16 PNG files. You can list a single texture for a static particle or multiple textures for an animated one. The particle provider controls which sprite is shown at each point in the particle's lifetime.

Client-Side Provider

Create a provider class in the common project's client/particle package. Extending TextureSheetParticle gives you sprite animation, gravity, and fading for free:

java
public class SparkleParticle extends TextureSheetParticle {
private final SpriteSet sprites;
protected SparkleParticle(ClientLevel level, double x, double y, double z,
double dx, double dy, double dz, SpriteSet sprites) {
super(level, x, y, z, dx, dy, dz);
this.sprites = sprites;
this.lifetime = 20 + random.nextInt(10);
this.gravity = 0.02f;
this.alpha = 1.0f;
pickSprite(sprites);
}
@Override
public void tick() {
super.tick();
setSpriteFromAge(sprites);
alpha = 1.0f - (float) age / (float) lifetime;
}
@Override
public ParticleRenderType getRenderType() {
return ParticleRenderType.PARTICLE_SHEET_TRANSLUCENT;
}
public static class Provider implements ParticleProvider<SimpleParticleType> {
private final SpriteSet sprites;
public Provider(SpriteSet sprites) {
this.sprites = sprites;
}
@Override
public Particle createParticle(SimpleParticleType type, ClientLevel level,
double x, double y, double z,
double dx, double dy, double dz) {
return new SparkleParticle(level, x, y, z, dx, dy, dz, sprites);
}
}
}

setSpriteFromAge automatically cycles through the sprite list based on the particle's age relative to its lifetime, producing a smooth animation. The fading alpha is calculated manually in tick.

Client-Side Registration

Register the provider from client-only code. On NeoForge, use RegisterParticleProvidersEvent on the mod bus:

java
// NeoForge: in a @EventBusSubscriber(bus = Bus.MOD, value = Dist.CLIENT) class
@SubscribeEvent
public static void onRegisterParticleProviders(RegisterParticleProvidersEvent event) {
event.registerSpriteSet(ParticleRegistry.SPARKLE.get(), SparkleParticle.Provider::new);
}
java
// Fabric: in ExampleModFabricClient.onInitializeClient()
ParticleFactoryRegistry.getInstance().register(
ParticleRegistry.SPARKLE.get(), SparkleParticle.Provider::new);

Spawning Particles

Always spawn particles from the server using level.sendParticles, which broadcasts to nearby clients automatically. Calling the client-only level.addParticle from server-side code will throw a ClassCastException:

java
// Server side (works on both loaders)
if (level instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(
ParticleRegistry.SPARKLE.get(),
pos.getX() + 0.5, pos.getY() + 1.0, pos.getZ() + 0.5,
15, // count
0.3, // spread X
0.2, // spread Y
0.3, // spread Z
0.05 // speed
);
}

If you are writing client-only code (inside a renderer or a client-side event handler), you can call Minecraft.getInstance().level.addParticle directly.

Testing In-Game

Add a temporary block break event handler that fires the particle above any broken block, then launch the client. Break a block and verify that the sparkle particles appear, fade out, and cycle through their sprite frames.

If no particles appear, check:

  • The particle description JSON exists in the correct path under assets/examplemod/particles/.
  • The texture files exist at the paths listed in the JSON.
  • The provider is registered (the client-side registration hook was called).
  • The client particle setting is not set to Minimal, which suppresses most non-essential particles.

For purposes of this tutorial, I added to our existing useWithoutItem method override in our ExampleBlockEntityBlock class:

java
@Override
protected InteractionResult useWithoutItem(BlockState pState, Level pLevel, BlockPos pPos, Player pPlayer, BlockHitResult pHitResult) {
if(!pPlayer.level().isClientSide) {
Dispatcher.sendToClient(new ExampleS2CPacket(42, "hello"), (ServerPlayer) pPlayer);
if (pLevel instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(
ParticleRegistry.SPARKLE.get(),
pPos.getX() + 0.5, pPos.getY() + 1.0, pPos.getZ() + 0.5,
15, // count
0.3, // spread X
0.2, // spread Y
0.3, // spread Z
0.05 // speed
);
}
}
return super.useWithoutItem(pState, pLevel, pPos, pPlayer, pHitResult);
}

You can find the source for this tutorial here:

View Source on GitHub
NEXT IN SERIES

Menus & Screens (MultiLoader 1.21+)

Extend ExampleBlockEntity with a nine-slot inventory, build an AbstractContainerMenu with correct slot layout and shift-click routing, create an AbstractContainerScreen with a custom GUI texture, and wire up screen registration on NeoForge and Fabric.

Continue →