By the end of this chapter, you’ll have built a particle system that brings magical powers to life. You’ll create four unique effects (Fire, Arcane, Shadow, and Poison), each with glowing particles that move, rotate, and fade. You’ll learn how to create particle emitters, write a custom shader for glowing effects, and use additive blending to make particles that feel magical.

Prerequisites: This is Chapter 6 of our Bevy tutorial series. Join our community for updates on new releases. Before starting, complete Chapter 1: Let There Be a Player, Chapter 2: Let There Be a World, Chapter 3: Let The Data Flow, Chapter 4: Let There Be Collisions, and Chapter 5: Let There Be Pickups, or clone the Chapter 5 code from this repository to follow along.

Combat System Demo

Before We Begin: I'm constantly working to improve this tutorial and make your learning journey enjoyable. Your feedback matters - share your frustrations, questions, or suggestions on Reddit/Discord/LinkedIn. Loved it? Let me know what worked well for you! Together, we'll make game development with Rust and Bevy more accessible for everyone.

Let’s Give Your Players Magic Powers

By the end of this chapter, you’ll learn:

  • How to spawn and update thousands of particles efficiently
  • Add variance for organic, natural looking effects
  • Custom shaders with additive blending for that magical glow
  • Building a flexible system that’s easy to extend
  • Give your player magical powers

Understanding Particle Systems

Comic Panel

A particle system spawns many small sprites that each:

  1. Spawn from an emitter with initial properties (position, velocity, color, size)
  2. Live for a short time, moving and changing
  3. Die when their lifetime expires

The magic is in the numbers: spawn enough particles with slight variations, and they combine to create complex, beautiful effects.

Building the Particle System

Each particle needs to be independent—moving, rotating, fading, and shrinking on its own. To achieve this, we need two types of properties: physics (how it moves) and visuals (how it looks). The physics properties like velocity, acceleration, and angular velocitygive particles realistic motion.

Physics + Visual Properties in Action

Watch particles move (velocity), rotate (angular velocity), shrink (scale curve), and fade (color curve)

Particles shouldn’t live forever. A fire particle needs to burn out, a magic spell needs to fade away. But particles also need smooth animations as they age, they should gradually fade, shrink, and change color over time, not just blink out of existence.

To make this happen, particles need to track two things: when to die and how far along they are in their life. That’s why we use a countdown timer paired with progress tracking.

A countdown timer (lifetime) tells us when to delete the particle, but not its progress value. A particle with 0.5s left could be 25% done (started at 2.0s) or 99% done (started at 0.51s).

We need both values:

  • lifetime - time remaining
  • max_lifetime - original duration

Then: progress = 1.0 - (lifetime / max_lifetime)

Now we know exactly where the particle is: 0% at birth, 50% at midpoint, 100% at death. This progress value drives all animations like color, size, opacity. Without both values, particles just blink on/off. With both, they transition smoothly.

Lifetime vs Progress

Watch two particles with different max_lifetimes die at different times

Particle Components

Now that we understand what properties particles need, let’s create these variables for our particle system. We’ll bundle them into a Particle component that tracks everything from physics to visuals.

Create particles folder inside src and add src/particles/components.rs:

// src/particles/components.rs
use bevy::prelude::*;

/// A single particle in the particle system
#[derive(Component, Clone)]
pub struct Particle {
    pub velocity: Vec3,           // Movement speed and direction (units/sec)
    pub lifetime: f32,             // Remaining time before death (seconds)
    pub max_lifetime: f32,         // Original lifetime for progress calculation
    pub scale: f32,                // Current size multiplier
    pub angular_velocity: f32,     // Rotation speed (radians/sec)
    pub acceleration: Vec3,        // Forces like gravity (units/sec²)
    // Color curve support (start → mid → end)
    pub start_color: Color,        // Color at birth (0% lifetime)
    pub mid_color: Color,          // Color at midpoint (50% lifetime)
    pub end_color: Color,          // Color at death (100% lifetime)
    // Scale curve support
    pub start_scale: f32,          // Size at birth
    pub end_scale: f32,            // Size at death (usually smaller)
}

Why three colors?

We animate color over the particle’s lifetime using a curve:

  • Start (bright) → Mid (dimmer) → End (fade to black)

This creates smooth transitions. A simple blend between two colors looks linear and boring. Three control points give us more expressive fading.

Particle Color Curve

Watch how a Shadow particle smoothly transitions through three color keyframes

The Particle struct holds all the data, but we need methods to:

  1. Create particles easily - A constructor that sets sensible defaults
  2. Customize particles - Builder methods to override specific properties
  3. Calculate animations - Methods that compute current color and scale based on lifetime progress

Without these methods, every system that renders particles would need to duplicate this logic. By centralizing it here, we ensure consistency and make the code easier to maintain.

Let’s implement the particle methods:

// Append to src/particles/components.rs

impl Particle {
    pub fn new(velocity: Vec3, lifetime: f32, scale: f32, start_color: Color) -> Self {
        Self {
            velocity,
            lifetime,
            max_lifetime: lifetime,
            scale,
            angular_velocity: 0.0,
            acceleration: Vec3::ZERO,
            start_color,
            mid_color: start_color,  // Default to same color
            end_color: start_color,
            start_scale: scale,
            end_scale: scale * 0.5,  // Default: shrink to half
        }
    }

    pub fn with_angular_velocity(mut self, angular_velocity: f32) -> Self {
        self.angular_velocity = angular_velocity;
        self
    }

    pub fn with_acceleration(mut self, acceleration: Vec3) -> Self {
        self.acceleration = acceleration;
        self
    }

    /// Set color curve for smooth color transitions
    pub fn with_color_curve(mut self, mid: Color, end: Color) -> Self {
        self.mid_color = mid;
        self.end_color = end;
        self
    }

    /// Set scale curve
    pub fn with_scale_curve(mut self, end_scale: f32) -> Self {
        self.end_scale = end_scale;
        self
    }

    /// Returns the normalized lifetime progress (0.0 to 1.0)
    pub fn progress(&self) -> f32 {
        1.0 - (self.lifetime / self.max_lifetime)
    }
    
    /// Get interpolated color based on lifetime progress
    pub fn current_color(&self) -> Color {
        let progress = self.progress();
        
        if progress < 0.5 {
            // First half: start → mid
            let t = progress * 2.0;  // Remap 0.0-0.5 to 0.0-1.0
            self.start_color.mix(&self.mid_color, t)
        } else {
            // Second half: mid → end
            let t = (progress - 0.5) * 2.0;  // Remap 0.5-1.0 to 0.0-1.0
            self.mid_color.mix(&self.end_color, t)
        }
    }
    
    /// Get interpolated scale based on lifetime progress
    pub fn current_scale(&self) -> f32 {
        let progress = self.progress();
        self.start_scale.lerp(self.end_scale, progress)
    }
}

The Builder Pattern

Methods like with_angular_velocity() and with_color_curve() use the builder pattern, a Rust idiom for constructing complex objects step-by-step. Each method:

  • Takes self (ownership of the particle)
  • Modifies one field
  • Returns self back

This lets us chain calls together:

// Pseudo code, don't use
Particle::new(velocity, 1.0, 2.0, Color::RED)
    .with_angular_velocity(3.14)
    .with_color_curve(Color::ORANGE, Color::BLACK)
    .with_scale_curve(0.1)

Clean, readable, and flexible. You only specify what you need to customize, everything else uses defaults from new().

The Calculation Methods

Methods like progress(), current_color(), and current_scale() are where the magic happens. They compute values based on the particle’s current state:

  • progress() - Converts remaining lifetime into a 0.0-1.0 percentage, so we know exactly where the particle is in its life cycle (just born at 0%, halfway through at 50%, about to die at 100%)
  • current_color() - Blends smoothly between the three colors based on progress, creating that magical fade effect where fire particles glow bright orange then dim to black, or poison clouds shift from sickly green to dark purple
  • current_scale() - Gradually shrinks the particle from full size to tiny as it ages, making effects feel more dynamic and preventing particles from just blinking out of existence

The current_color() method:

  1. First, it calls progress() to get a value between 0.0 (particle just spawned) and 1.0 (particle about to die)
  2. Then it splits the lifetime into two halves:
    • First half (0% to 50%): Blends from start_color to mid_color
    • Second half (50% to 100%): Blends from mid_color to end_color

What’s .mix()?

Bevy’s color interpolation method. color1.mix(&color2, 0.5) gives you 50% between the two colors.

Why the * 2.0?
The mix() function needs input from 0.0 to 1.0 to do a complete blend. During the first half of life, progress only reaches 0.5. That’s not enough, it would only blend halfway. Multiplying by 2 makes progress reach 1.0 by the halfway point, giving mix() the full range it needs.

The current_scale() method:

You know how good particle effects don’t just blink out,they shrink and fade away naturally? That’s what current_scale() creates. To achieve this, we gradually reduce the particle’s size from its starting size to a tiny ending size based on how much of its life has passed.

For example, imagine a particle that starts at size 2.0 and should end at 0.5:

  • At 0% progress: size is 2.0 (full size, just spawned)
  • At 50% progress: size is 1.25 (halfway between)
  • At 100% progress: size is 0.5 (tiny, about to disappear)

The particle smoothly shrinks over time, creating that satisfying dissipation effect.

Particle Emitter

Now we need something to actually create particles. That’s the job of the ParticleEmitter component. Think of it as a particle factory attached to an entity (like your player character).

Comic Panel

The emitter needs to track:

  • When to spawn - A timer that ticks down and triggers particle creation
  • How many to spawn - Burst size (e.g., 5 particles at once)
  • What kind to spawn - The template for creating particles
  • Whether it’s active - Can be turned on/off
  • One-shot vs continuous - Fire once or keep firing

Here’s the emitter component:

// Append to src/particles/components.rs

/// Configuration for a particle emitter
#[derive(Component, Clone)]
pub struct ParticleEmitter {
    pub spawn_timer: Timer,
    pub particles_per_spawn: u32,
    pub particle_config: ParticleConfig,
    pub active: bool,
    pub one_shot: bool,
    pub has_spawned: bool,
}

impl ParticleEmitter {
    pub fn new(spawn_rate: f32, particles_per_spawn: u32, particle_config: ParticleConfig) -> Self {
        Self {
            spawn_timer: Timer::from_seconds(spawn_rate, TimerMode::Repeating),
            particles_per_spawn,
            particle_config,
            active: true,
            one_shot: false,
            has_spawned: false,
        }
    }

    pub fn one_shot(mut self) -> Self {
        self.one_shot = true;
        self
    }
}

How does one_shot work?

Without one_shot, the emitter keeps spawning particles forever (or until you manually set active = false). This is perfect for continuous effects like a torch flame or a magic aura.

With one_shot = true, the emitter spawns particles once and then automatically deactivates. This is ideal for one-time effects like a spell cast or an explosion—you don’t want those repeating every frame!

Particle Configuration

The ParticleConfig struct is the DNA for creating particles. It defines all the properties each particle should have, but with a twist: variance.

Variance

Without variance (left) vs with variance (right) - see the difference!

Without variance, every particle would be identical (boring!). With variance, each particle gets slightly randomized values, creating organic, natural looking effects. For each property, we store:

  • Base value - The target value (e.g., lifetime: 1.0 seconds)
  • Variance - How much to randomize it (e.g., lifetime_variance: 0.2 means ±0.2 seconds)

So a particle might live for 0.8 seconds, another for 1.1 seconds, another for 0.95 seconds, all slightly different, making the effect feel alive.

Now let’s create the struct that holds all particle properties. This ParticleConfig serves as a template that particle emitters use to spawn new particles. Instead of hardcoding values, we define them once in a config and reuse it.

Here’s the configuration struct:

// Append to src/particles/components.rs

/// Configuration for spawning particles
#[derive(Clone)]
pub struct ParticleConfig {
    pub lifetime: f32,
    pub lifetime_variance: f32,
    pub speed: f32,
    pub speed_variance: f32,
    pub direction: Vec3,
    pub direction_variance: f32,  // In radians
    pub scale: f32,
    pub scale_variance: f32,
    pub color: Color,
    pub angular_velocity: f32,
    pub angular_velocity_variance: f32,
    pub acceleration: Vec3,
    pub emission_shape: EmissionShape,
}

impl Default for ParticleConfig {
    fn default() -> Self {
        Self {
            lifetime: 1.0,
            lifetime_variance: 0.1,
            speed: 100.0,
            speed_variance: 10.0,
            direction: Vec3::X,
            direction_variance: 0.1,
            scale: 1.0,
            scale_variance: 0.1,
            color: Color::WHITE,
            angular_velocity: 0.0,
            angular_velocity_variance: 0.0,
            acceleration: Vec3::ZERO,
            emission_shape: EmissionShape::Point,
        }
    }
}

#[derive(Clone)]
pub enum EmissionShape {
    Point,
    Circle { radius: f32 },
    Cone { angle: f32 },
}

Understanding the config attributes:

  • lifetime / lifetime_variance - How long particles exist (seconds)
  • speed / speed_variance - Initial velocity magnitude
  • direction / direction_variance - Which way particles fly (direction_variance in radians creates spread)
  • scale / scale_variance - Size of particles
  • color - Base particle tint (can be HDR values above 1.0)
  • angular_velocity / angular_velocity_variance - How fast particles spin
  • acceleration - Constant force applied (like gravity or wind)
  • emission_shape - Where particles spawn (Point, Circle, or Cone)

Particle System Updates

So far we’ve defined the data structures, what particles and emitters are. Now we need the systems, the code that actually does things every frame.

We need two key behaviors:

  1. Spawn particles - Check each emitter’s timer, and when it fires, create new particle entities
  2. Update particles - Move them, rotate them, fade their colors, shrink their size, and delete them when they die

Without these systems, our components would just sit there doing nothing. Let’s start with the spawning system. Create src/particles/systems.rs:

To spawn particles, we need two functions working together:

  1. update_emitters - Runs every frame, checks each emitter’s timer, and when the timer fires, triggers particle creation
  2. spawn_particle - A helper function that creates a single particle entity with randomized properties
// src/particles/systems.rs
use super::components::*;
use super::material::ParticleMaterial;
use bevy::prelude::*;
use rand::Rng;

/// System to update particle emitters and spawn new particles
pub fn update_emitters(
    mut commands: Commands,
    time: Res<Time>,
    mut emitters: Query<(Entity, &mut ParticleEmitter, &GlobalTransform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ParticleMaterial>>,
) {
    let mut rng = rand::thread_rng();

    for (entity, mut emitter, global_transform) in emitters.iter_mut() {
        if !emitter.active {
            continue;
        }

        // Handle one-shot emitters
        if emitter.one_shot && emitter.has_spawned {
            emitter.active = false;
            continue;
        }

        emitter.spawn_timer.tick(time.delta());

        if emitter.spawn_timer.just_finished() {
            emitter.has_spawned = true;

            // Spawn particles
            for i in 0..emitter.particles_per_spawn {
                spawn_particle(
                    &mut commands,
                    &emitter.particle_config,
                    global_transform,
                    &mut rng,
                    &mut meshes,
                    &mut materials,
                    Some(entity),
                    i,
                );
            }

            if emitter.one_shot {
                emitter.active = false;
            }
        }
    }
}

How update_emitters Works

This system runs every frame and manages all particle emitters in the game. Here’s the flow:

  1. Setup - Create a random number generator (we’ll need it for variance)
  2. Loop through all emitters - The Query gives us every entity with a ParticleEmitter component
  3. Skip inactive emitters - If active is false, don’t spawn anything
  4. Handle one-shot logic - If it’s a one-shot emitter that already spawned, deactivate it
  5. Tick the timer - Advance the spawn timer by the frame’s delta time
  6. Check if timer finished - When the timer completes, it’s time to spawn!
  7. Spawn a burst - Create particles_per_spawn particles using the spawn_particle helper
  8. Deactivate one-shots - If it’s a one-shot emitter, turn it off after spawning

Emitters don’t spawn particles every frame, they use a timer to control the spawn rate. A timer of 0.1 seconds means 10 bursts per second.

What’s rand::thread_rng()?

Creates a random number generator for this thread. We use it to add variance to particle properties, each particle gets slightly different lifetime, speed, direction, etc.

Now the particle spawning function:

update_emitters handles the emitter’s timer and decides when to spawn particles. But it delegates the actual particle creation to a helper function called spawn_particle. This function takes the emitter’s configuration and creates a single particle entity with randomized properties (lifetime, speed, direction, etc.), a visual mesh, and all the necessary Bevy components.

// Append to src/particles/systems.rs

/// Helper function to spawn a single particle
pub fn spawn_particle(
    commands: &mut Commands,
    config: &ParticleConfig,
    global_transform: &GlobalTransform,
    rng: &mut rand::rngs::ThreadRng,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<ParticleMaterial>>,
    owner: Option<Entity>,
    _particle_index: u32,
) {
    // Calculate randomized values
    let lifetime =
        config.lifetime + rng.gen_range(-config.lifetime_variance..config.lifetime_variance);
    let speed = config.speed + rng.gen_range(-config.speed_variance..config.speed_variance);
    let scale = config.scale + rng.gen_range(-config.scale_variance..config.scale_variance);
    let angular_velocity = config.angular_velocity
        + rng.gen_range(-config.angular_velocity_variance..config.angular_velocity_variance);

    // Calculate direction with variance
    let base_direction = config.direction.normalize_or_zero();
    let direction = if config.direction_variance > 0.0 {
        apply_direction_variance(base_direction, config.direction_variance, rng)
    } else {
        base_direction
    };

    // Calculate emission offset based on shape
    let emission_offset = match config.emission_shape {
        EmissionShape::Point => Vec3::ZERO,
        EmissionShape::Circle { radius } => {
            let angle = rng.gen_range(0.0..std::f32::consts::TAU);
            let distance = rng.gen_range(0.0..radius);
            Vec3::new(angle.cos() * distance, angle.sin() * distance, 0.0)
        }
        EmissionShape::Cone { angle } => {
            let cone_angle = rng.gen_range(-angle..angle);
            let rotated = rotate_vector_2d(base_direction, cone_angle);
            rotated * rng.gen_range(0.0..1.0)
        }
    };

    let velocity = direction * speed;

    // Get position directly from GlobalTransform
    let emitter_position = global_transform.translation();
    let mut position = emitter_position + emission_offset;

    // Ensure particles are at a visible Z layer (above player)
    position.z = 25.0;

    // Create particle with color curves
    let start_color = config.color;
    // Create color curve: bright → slightly dimmer → fade to black
    let mid_color = {
        let linear = config.color.to_linear();
        Color::LinearRgba(LinearRgba::new(
            linear.red * 0.7,
            linear.green * 0.7,
            linear.blue * 0.7,
            linear.alpha,
        ))
    };
    let end_color = Color::srgba(
        config.color.to_linear().red * 0.3,
        config.color.to_linear().green * 0.3,
        config.color.to_linear().blue * 0.3,
        0.0,
    ); // Fading to black

    let particle = Particle::new(velocity, lifetime, scale, start_color)
        .with_angular_velocity(angular_velocity)
        .with_acceleration(config.acceleration)
        .with_color_curve(mid_color, end_color)
        .with_scale_curve(scale * 0.2); // Shrink to 20%

    // Create a mesh for the particle
    let size = 24.0 * scale;
    let mesh = meshes.add(Rectangle::new(size, size));
    let material = materials.add(ParticleMaterial::new(start_color));

    commands.spawn((
        particle,
        Mesh2d(mesh),
        MeshMaterial2d(material),
        Transform::from_translation(position),
    ));
}

How spawn_particle Works

This function creates a single particle entity with randomized properties.

Step 1: Add randomness to basic properties

  • We don’t want every particle to be identical, it won’t look natural
  • Take the base values (how long it lives, how fast it moves, how big it is, how fast it spins)
  • Add a random amount within the variance range
  • Now each particle is unique!

Step 2: Randomize the direction

  • Particles shouldn’t all fly in exactly the same direction
  • Start with the base direction (e.g., “fly to the right”)
  • If there’s direction variance, randomly rotate it a bit, otherwise keep it straight

Step 3: Pick a spawn position within the emitter’s shape

  • Point: all particles spawn at the exact same spot (like a laser beam origin)
  • Circle: particles spawn randomly within a circular area (like a campfire)
  • Cone: particles spawn in a cone shape (like a flamethrower)

Step 4: Figure out where the particle starts

  • Combine direction and speed to get velocity (how it moves each frame)
  • Start at the emitter’s position in the world
  • Add the shape offset from Step 3
  • Put it at Z = 25.0 so it appears above the player

Step 5: Set up the color fade animation

  • Particles should fade out as they die, not just disappear
  • Start: full brightness (the color you configured)
  • Middle: 70% brightness (getting dimmer)
  • End: 30% brightness and transparent (fading to nothing)

Step 6: Create the particle with all its settings

  • Use the builder pattern to chain all the properties together
  • Set velocity, lifetime, scale, rotation speed, color curve, scale curve
  • Everything is configured and ready to go

Step 7: Make a visual square for the particle

  • Create a 24-pixel square mesh (scaled by the particle’s size)
  • Create a material that uses the particle’s starting color
  • This is what you’ll actually see on screen

Step 8: Tell Bevy to create the particle entity

  • Bundle everything together: the particle data, the mesh, the material, the position
  • Bevy spawns a new entity with all these components
  • The particle is now alive in the game world!

Why std::f32::consts::TAU?

TAU is 2π (approximately 6.28), a full circle in radians. For the Circle emission shape, we pick a random angle from 0 to TAU to get a point anywhere around the circle.

What’s .normalize_or_zero()?

Converts a vector to length 1.0, making it a pure direction. If the vector is zero (no direction), it returns (0,0,0) instead of nan (Not a Number). This is safer than .normalize() which can panic on zero vectors.

Now add the helper functions:

These are small utility functions that help with the math in spawn_particle. They handle the geometry of spreading particles in different directions, essential for creating cone and spray effects instead of straight lines.

// Append to src/particles/systems.rs

/// Apply directional variance to a vector
fn apply_direction_variance(
    direction: Vec3,
    variance: f32,
    rng: &mut rand::rngs::ThreadRng,
) -> Vec3 {
    let angle = rng.gen_range(-variance..variance);
    rotate_vector_2d(direction, angle)
}

/// Rotate a 2D vector by an angle (in radians)
fn rotate_vector_2d(vec: Vec3, angle: f32) -> Vec3 {
    let cos = angle.cos();
    let sin = angle.sin();
    Vec3::new(vec.x * cos - vec.y * sin, vec.x * sin + vec.y * cos, vec.z)
}

What’s this rotation math?

This is a 2D rotation matrix. To rotate a vector by an angle:

  • New X = old X × cos(angle) - old Y × sin(angle)
  • New Y = old X × sin(angle) + old Y × cos(angle)

Now the particle update system:

We’ve created the spawning system, but now we need to bring particles to life. The update_particles system runs every frame and handles everything that happens during a particle’s lifetime: moving it, spinning it, fading its color, shrinking its size, and removing it when it dies.

// Append to src/particles/systems.rs

/// System to update particle lifetime and properties
pub fn update_particles(
    mut commands: Commands,
    time: Res<Time>,
    mut particles: Query<(
        Entity,
        &mut Particle,
        &mut Transform,
        &MeshMaterial2d<ParticleMaterial>,
    )>,
    mut materials: ResMut<Assets<ParticleMaterial>>,
) {
    for (entity, mut particle, mut transform, material_handle) in particles.iter_mut() {
        particle.lifetime -= time.delta_secs();

        if particle.lifetime <= 0.0 {
            commands.entity(entity).despawn();
            continue;
        }

        // Update position
        let acceleration = particle.acceleration;
        particle.velocity += acceleration * time.delta_secs();
        transform.translation += particle.velocity * time.delta_secs();

        // Update rotation
        transform.rotate_z(particle.angular_velocity * time.delta_secs());

        // Apply color curve interpolation
        let current_color = particle.current_color();

        // Apply scale curve interpolation
        let current_scale = particle.current_scale();
        transform.scale = Vec3::splat(current_scale);

        // Update material color
        if let Some(material) = materials.get_mut(&material_handle.0) {
            material.color = current_color.to_linear();
        }
    }
}

Breaking it down:

  1. Countdown lifetime: Subtract frame time from particle’s remaining life
  2. Despawn dead particles: When lifetime hits zero, remove the entity
  3. Apply acceleration: Forces modify velocity over time
  4. Update position: Move by velocity each frame
  5. Rotate: Spin the particle based on angular velocity
  6. Update color: Use the curve from current_color()
  7. Update scale: Shrink/grow using current_scale()
  8. Update material: Push the new color to the shader

Finally, add emitter cleanup:

// Append to src/particles/systems.rs

/// System to clean up inactive emitters that are one-shot
pub fn cleanup_finished_emitters(
    mut commands: Commands,
    emitters: Query<(Entity, &ParticleEmitter)>,
) {
    for (entity, emitter) in emitters.iter() {
        if emitter.one_shot && !emitter.active {
            commands.entity(entity).despawn();
        }
    }
}

This removes one-shot emitters after they’ve spawned their particles. Continuous emitters stick around until manually despawned.

The Shader

We now have particles spawning, moving, and dying. But they still look like flat colored squares. To make them glow like magical energy, we need a custom shader that runs on the GPU.

What is a shader?

A shader is a small program that runs on your GPU (graphics card) for every pixel on screen. While our Rust code runs on the CPU and manages game logic, shaders run massively in parallel on the GPU to create visual effects.

What we’re achieving:

We’re creating a radial glow effect where each particle is bright and intense at the center, smoothly fading to transparent at the edges. This makes particles look like glowing orbs of energy instead of flat squares.

Shader Glow Effect Comparison

Without shader (left) vs With radial gradient shader (right)

The language:

This shader is written in WGSL (WebGPU Shading Language), Bevy’s shader language. It’s similar to Rust in some ways but designed specifically for GPU programming.

Create the folder shaders in src/assets and add the shader file particle_glow.wgsl, the final path should be src/assets/shaders/particle_glow.wgsl.

// src/assets/shaders/particle_glow.wgsl
// Custom shader for particles
// Creates a radial gradient glow effect with additive blending
#import bevy_sprite::mesh2d_vertex_output::VertexOutput

@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> color: vec4<f32>;

@fragment
fn fragment(mesh: VertexOutput) -> @location(0) vec4<f32> {
    // Calculate distance from center (UV space is 0-1)
    let center = vec2<f32>(0.5, 0.5);
    let dist = distance(mesh.uv, center) * 2.0; // *2 to normalize to 0-1
    
    // Create radial gradient
    // Bright center → fades to edges
    let radial = 1.0 - smoothstep(0.0, 1.0, dist);
    
    // Add extra glow in the center
    let glow = pow(1.0 - dist, 3.0);
    
    // Combine radial gradient with center glow
    let intensity = radial * 0.7 + glow * 0.5;
    
    // Boost brightness near center for hot glow effect
    let brightness = 1.0 + glow * 0.5;
    
    // Apply to color (supports HDR - values > 1.0)
    let final_rgb = color.rgb * brightness;
    let final_alpha = color.a * intensity;
    
    return vec4<f32>(final_rgb, final_alpha);
}

How the shader creates the glow effect:

Inputs the shader receives:

  • color - The particle’s color sent from Rust code via ParticleMaterial (remember the #[uniform(0)] binding)
  • mesh.uv - The pixel’s position on the particle square. When Bevy renders a sprite with Material2d, it automatically creates a quad (rectangle) mesh and assigns UV coordinates to each corner: (0,0) at bottom-left, (1,1) at top-right. The GPU interpolates these for each pixel in between.

What’s a mesh and what are UV coordinates?

A mesh is a 3D shape made of triangles. For 2D sprites, Bevy creates a simple quad (2 triangles forming a rectangle) to display the image.

UV coordinates are like a map that tells the shader where each pixel is on that rectangle. Think of it like a grid:

  • U goes left to right (0.0 = left edge, 1.0 = right edge)
  • V goes bottom to top (0.0 = bottom edge, 1.0 = top edge)
  • So (0.5, 0.5) is the exact center of the particle

When the shader runs, every pixel knows its UV position. With these inputs (color and UV coordinates), the shader creates a radial gradient by calculating each pixel’s distance from the particle’s center. Pixels near the center get bright colors, while pixels at the edges fade to transparent.

Here’s the technique:

  1. Calculate distance - Measure how far this pixel is from the particle center
  2. Create smooth falloff - Use smoothstep to gradually fade from bright (center) to dark (edges)
  3. Boost center brightness - Multiply center pixels by values above 1.0 for that “hot core” effect
  4. Combine - Mix the smooth fade with the bright center for a natural-looking glow

Without the shader, particles are just flat colored squares. With it, they become glowing orbs of energy.

What’s smoothstep?

A function that creates smooth transitions. smoothstep(edge0, edge1, x) returns 0 when x is at edge0, 1 when x is at edge1, and smoothly transitions between them. Unlike a straight line transition, it starts slow, speeds up in the middle, then slows down at the end, creating natural looking fades.

Custom Shader Material

Now that we understand shaders, let’s create the Rust code that uses our shader. The ParticleMaterial struct is our bridge between Rust code and the GPU shader, it holds the particle’s color and tells Bevy which shader file to use for rendering.

Create src/particles/material.rs:

// src/particles/material.rs
use bevy::{
    prelude::*,
    reflect::TypePath,
    render::render_resource::{
        AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, ColorWrites,
        RenderPipelineDescriptor, SpecializedMeshPipelineError,
    },
    shader::ShaderRef,
    sprite_render::{AlphaMode2d, Material2d, Material2dKey},
    mesh::MeshVertexBufferLayoutRef,
};

/// Custom material for particles with radial gradient shader and additive blending
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct ParticleMaterial {
    #[uniform(0)]
    pub color: LinearRgba,
}

impl ParticleMaterial {
    pub fn new(color: Color) -> Self {
        Self {
            color: color.to_linear(),
        }
    }
}

What’s AsBindGroup?

This macro tells Bevy how to send data from your Rust code to the GPU shader. Think of it like packing a box to ship: the #[uniform(0)] label says “put the color value in slot 0 so the shader can find it.”

Understanding the terms:

  • Material: A material defines how a surface looks when rendered. It combines a shader (the rendering program) with properties (like color). Our ParticleMaterial is a custom material specifically for particles.

  • Fragment shader: A shader program that runs for each pixel being drawn. It calculates the final color of that pixel. Our fragment shader creates the radial glow effect.

  • Alpha blending: How transparent objects are combined with what’s behind them. Normal alpha blending makes things see-through. Additive blending (what we use) adds brightness values together for glowing effects.

  • Specialize: Customizing the rendering pipeline for this specific material. We use it to configure additive blending instead of normal transparency.

Now implement the Material2d trait:

The Material2d trait tells Bevy how to render our custom material. We implement three methods:

  • fragment_shader() - Returns the path to our shader file
  • alpha_mode() - Enables transparency blending
  • specialize() - Configures the rendering pipeline for additive blending
// Append to src/particles/material.rs

impl Material2d for ParticleMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/particle_glow.wgsl".into()
    }

    fn alpha_mode(&self) -> AlphaMode2d {
        AlphaMode2d::Blend
    }

    fn specialize(
        descriptor: &mut RenderPipelineDescriptor,
        _layout: &MeshVertexBufferLayoutRef,
        _key: Material2dKey<Self>,
    ) -> Result<(), SpecializedMeshPipelineError> {
        // Set up additive blending for glowing effect
        if let Some(fragment) = &mut descriptor.fragment {
            if let Some(target) = fragment.targets.first_mut() {
                if let Some(target_state) = target.as_mut() {
                    target_state.blend = Some(BlendState {
                        color: BlendComponent {
                            src_factor: BlendFactor::SrcAlpha,
                            dst_factor: BlendFactor::One, // Additive!
                            operation: BlendOperation::Add,
                        },
                        alpha: BlendComponent {
                            src_factor: BlendFactor::One,
                            dst_factor: BlendFactor::One,
                            operation: BlendOperation::Add,
                        },
                    });
                    target_state.write_mask = ColorWrites::ALL;
                }
            }
        }
        Ok(())
    }
}

The specialize function configures the GPU’s rendering pipeline for our particle material. It tells the GPU how to blend each rendered particle with what’s already on screen.

What this means for rendered particles: When a particle is drawn, the GPU needs to know how to combine its color with the background. Normal transparency makes overlapping particles darker. Additive blending makes them brighter and glowing. This is what creates that magical fire/magic look.

Here’s what the function does:

  1. Access the pipeline descriptor - Gets the configuration for how this material will be rendered
  2. Find the color target - Locates where color output is defined
  3. Set up additive blending - Configures the blend mode:
    • src_factor: SrcAlpha - Multiply particle color by its transparency
    • dst_factor: One - Keep the background color at full strength (don’t darken it)
    • operation: Add - Add them together

Result: Overlapping particles add their brightness together, creating intense glows where they overlap. Ten overlapping fire particles create a bright white-hot center!

What’s additive blending?

Normal alpha blending: new_color = particle_color * alpha + background * (1 - alpha)

Additive blending: new_color = particle_color + background

Overlapping particles add their brightness together, creating intense glows. This is how fire, magic, and explosions get that magical glowing look.

Comic Panel

Particles Module Plugin

Create src/particles/mod.rs:

// src/particles/mod.rs
pub mod components;
pub mod material;
pub mod systems;

use crate::state::GameState;
use bevy::{prelude::*, sprite_render::Material2dPlugin};

pub use material::*;
pub use systems::*;

pub struct ParticlesPlugin;

impl Plugin for ParticlesPlugin {
    fn build(&self, app: &mut App) {
        info!("Initializing ParticlesPlugin");
        app.add_plugins(Material2dPlugin::<ParticleMaterial>::default())
            .add_systems(
                Update,
                (update_emitters, update_particles, cleanup_finished_emitters)
                    .chain()
                    .run_if(in_state(GameState::Playing)),
            );
        info!("ParticlesPlugin initialized");
    }
}

Why .chain()?

Systems in a chain run sequentially in the order specified. We want:

  1. update_emitters - Spawn new particles
  2. update_particles - Update existing particles
  3. cleanup_finished_emitters - Remove dead emitters

This prevents edge cases where an emitter spawns particles then immediately despawns.

Creating the Combat Module

Now that we have a particle system, let’s build the combat system that uses it! Create a new folder src/combat/ for our combat system.

Power Types

Different powers need different behaviors and visuals. Let’s start by defining what makes each power unique.

Comic Panel

Create src/combat/power_type.rs:

// src/combat/power_type.rs
use bevy::prelude::*;
use crate::particles::components::{EmissionShape, ParticleConfig};

/// The different magical powers available
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PowerType {
    #[default]
    Fire,
    Arcane,
    Shadow,
    Poison,
}

Now let’s define the visual configuration for each power. We’ll separate visuals from behavior (future chapters will add damage, collision, etc.):

// Append to src/combat/power_type.rs

/// Visual configuration for a power - decoupled from behavior
#[derive(Clone)]
pub struct PowerVisuals {
    pub primary: ParticleConfig,
    pub core: Option<ParticleConfig>,
    pub particles_per_spawn: u32,
    pub core_particles_per_spawn: u32,
}

What’s the difference between primary and core?

Many effects have two layers:

  • Primary: The outer glow/trail (lots of particles, less bright)
  • Core: The bright center (fewer particles, very bright)

Imagine a fireball, the core is the white-hot center, the primary is the orange flames around it. Not all powers need a core, poison is just a single layer of green particles.

Now we can use the ParticleConfig and EmissionShape types we defined earlier! Let’s implement the visual configurations for each power type:

Now we’ll configure how each power looks and behaves using the ParticleConfig struct. Each power gets unique visual properties (colors, speeds, sizes, variances) that define its character. Fire gets HDR orange colors and wide spread, while Shadow uses tight precision with many fast particles.

// Append to src/combat/power_type.rs

impl PowerType {
    /// Get visual configuration for this power
    pub fn visuals(&self, direction: Vec3) -> PowerVisuals {
        match self {
            PowerType::Fire => Self::fire_visuals(direction),
            PowerType::Arcane => Self::arcane_visuals(direction),
            PowerType::Shadow => Self::shadow_visuals(direction),
            PowerType::Poison => Self::poison_visuals(direction),
        }
    }

    fn fire_visuals(direction: Vec3) -> PowerVisuals {
        PowerVisuals {
            primary: ParticleConfig {
                lifetime: 1.0,
                lifetime_variance: 0.2,
                speed: 350.0,
                speed_variance: 40.0,
                direction,
                direction_variance: 0.12,
                scale: 1.5,
                scale_variance: 0.5,
                color: Color::srgb(3.0, 0.5, 0.1),  // Bright orange-red
                angular_velocity: 3.0,
                angular_velocity_variance: 2.0,
                acceleration: Vec3::ZERO,
                emission_shape: EmissionShape::Circle { radius: 10.0 },
            },
            core: Some(ParticleConfig {
                lifetime: 0.8,
                lifetime_variance: 0.2,
                speed: 350.0,
                speed_variance: 30.0,
                direction,
                direction_variance: 0.08,
                scale: 1.0,
                scale_variance: 0.3,
                color: Color::srgb(4.0, 1.0, 0.2),  // Very bright yellow-white
                angular_velocity: 5.0,
                angular_velocity_variance: 2.0,
                acceleration: Vec3::ZERO,
                emission_shape: EmissionShape::Circle { radius: 5.0 },
            }),
            particles_per_spawn: 5,
            core_particles_per_spawn: 3,
        }
    }

    fn arcane_visuals(direction: Vec3) -> PowerVisuals {
        PowerVisuals {
            primary: ParticleConfig {
                lifetime: 1.2,
                lifetime_variance: 0.2,
                speed: 300.0,
                speed_variance: 30.0,
                direction,
                direction_variance: 0.05,  // Very precise
                scale: 1.2,
                scale_variance: 0.3,
                color: Color::srgb(0.5, 0.8, 2.5),  // Blue arcane energy
                angular_velocity: 2.0,
                angular_velocity_variance: 1.0,
                acceleration: Vec3::ZERO,
                emission_shape: EmissionShape::Circle { radius: 6.0 },
            },
            core: Some(ParticleConfig {
                lifetime: 1.0,
                lifetime_variance: 0.1,
                speed: 300.0,
                speed_variance: 20.0,
                direction,
                direction_variance: 0.03,  // Even more precise
                scale: 0.8,
                scale_variance: 0.2,
                color: Color::srgb(0.9, 0.95, 3.0),  // Bright white-blue
                angular_velocity: 0.5,
                angular_velocity_variance: 0.5,
                acceleration: Vec3::ZERO,
                emission_shape: EmissionShape::Point,
            }),
            particles_per_spawn: 4,
            core_particles_per_spawn: 2,
        }
    }

    fn shadow_visuals(direction: Vec3) -> PowerVisuals {
        PowerVisuals {
            primary: ParticleConfig {
                lifetime: 0.6,  // Short-lived
                lifetime_variance: 0.15,
                speed: 600.0,  // Very fast
                speed_variance: 100.0,
                direction,
                direction_variance: 0.04,
                scale: 1.0,
                scale_variance: 0.4,
                color: Color::srgb(0.6, 0.2, 1.2),  // Dark purple
                angular_velocity: 8.0,  // Spins fast
                angular_velocity_variance: 4.0,
                acceleration: Vec3::ZERO,
                emission_shape: EmissionShape::Point,
            },
            core: Some(ParticleConfig {
                lifetime: 0.5,
                lifetime_variance: 0.1,
                speed: 650.0,
                speed_variance: 80.0,
                direction,
                direction_variance: 0.02,
                scale: 1.3,
                scale_variance: 0.3,
                color: Color::srgb(1.0, 0.5, 1.8),  // Brighter purple core
                angular_velocity: 12.0,
                angular_velocity_variance: 5.0,
                acceleration: Vec3::ZERO,
                emission_shape: EmissionShape::Point,
            }),
            particles_per_spawn: 7,  // Many particles
            core_particles_per_spawn: 3,
        }
    }

    fn poison_visuals(direction: Vec3) -> PowerVisuals {
        PowerVisuals {
            primary: ParticleConfig {
                lifetime: 1.5,  // Long-lived
                lifetime_variance: 0.4,
                speed: 200.0,  // Slow
                speed_variance: 50.0,
                direction,
                direction_variance: 0.25,  // Spreads a lot
                scale: 1.8,  // Large particles
                scale_variance: 0.6,
                color: Color::srgb(0.3, 2.0, 0.3),  // Toxic green
                angular_velocity: 1.0,
                angular_velocity_variance: 2.0,
                acceleration: Vec3::new(0.0, 20.0, 0.0),  // Rises slightly
                emission_shape: EmissionShape::Circle { radius: 15.0 },
            },
            core: None,  // No core - just a cloud
            particles_per_spawn: 6,
            core_particles_per_spawn: 0,
        }
    }
}

The configuration numbers define each power’s unique character. Fire has high speed (350), wide spread (0.12 variance), and HDR orange colors. Shadow has very high speed (500), tight beam (0.05 variance), and dark purple.

Why Color::srgb(3.0, 0.5, 0.1) with values above 1.0?

Values above 1.0 create HDR (High Dynamic Range) colors that glow brighter than normal. When combined with additive blending (from our particle shader), these create the magical glow effect. It’s like cranking the brightness past 100%, perfect for fire and magic.

What’s direction_variance?

Controls how much particles spread. Low variance (0.03 for Arcane) means particles stay in a tight beam. High variance (0.25 for Poison) creates a wide, spreading cloud. It’s measured in radians.

Comic Panel

Now notice the design pattern here: each power has a distinct character expressed through numbers:

Power Character Speed Lifetime Spread Particles
Fire Hot, chaotic Medium Short Medium Medium
Arcane Precise, magical Medium Long Tight Few
Shadow Fast, deadly Fast Very short Tight Many
Poison Spreading, lingering Slow Long Wide Medium

This data-driven approach means adding a new power is just adding a new function with different numbers, no code logic changes needed.

Player Combat Component

Now we need a component to attach to the player that tracks their current power and prevents rapid-fire spam.

Comic Panel

We use a countdown Timer that must finish before the next attack. When the player attacks, we check if the timer is finished. If yes, spawn particles and reset the timer back to 0.5 seconds. If no, ignore the input. This creates a smooth attack rate without complex cooldown tracking.

Create src/combat/player_combat.rs:

// src/combat/player_combat.rs
use super::power_type::PowerType;
use bevy::prelude::*;

/// Attach to any entity that can use powers (player, NPCs)
#[derive(Component)]
pub struct PlayerCombat {
    pub power_type: PowerType,
    pub cooldown: Timer,
}

impl Default for PlayerCombat {
    fn default() -> Self {
        Self {
            power_type: PowerType::Fire,
            cooldown: Timer::from_seconds(0.5, TimerMode::Once),
        }
    }
}

impl PlayerCombat {
    pub fn new(power_type: PowerType) -> Self {
        Self {
            power_type,
            cooldown: Timer::from_seconds(0.5, TimerMode::Once),
        }
    }

    pub fn with_cooldown(mut self, seconds: f32) -> Self {
        self.cooldown = Timer::from_seconds(seconds, TimerMode::Once);
        self
    }
}

What’s TimerMode::Once?

Timers in Bevy can be Once (stop when finished) or Repeating (restart automatically). For cooldowns, we want Once, the timer counts down from 0.5 seconds to 0, then stops. We manually reset it when the player attacks.

Why 0.5 seconds?

This creates a ~2 attacks per second rate. Too fast feels spammy, too slow feels unresponsive. You can tweak this with .with_cooldown() for different weapons or upgrades.

Combat Systems

Now for the system that handles player input and spawns projectiles.

Create src/combat/systems.rs:

// src/combat/systems.rs
use super::player_combat::PlayerCombat;
use super::power_type::{PowerType, PowerVisuals};
use crate::characters::facing::Facing;
use crate::characters::input::Player;
use crate::particles::components::ParticleEmitter;
use bevy::prelude::*;

/// Marker for projectile effects
#[derive(Component)]
pub struct ProjectileEffect {
    pub power_type: PowerType,
}

pub fn handle_power_input(
    mut commands: Commands,
    input: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut player_query: Query<(&GlobalTransform, &Facing, &mut PlayerCombat), With<Player>>,
) {
    let Ok((global_transform, facing, mut combat)) = player_query.single_mut() else {
        return;
    };

    combat.cooldown.tick(time.delta());

    let ctrl_pressed =
        input.just_pressed(KeyCode::ControlLeft) || input.just_pressed(KeyCode::ControlRight);

    if !ctrl_pressed {
        return;
    }

    // Only fire if cooldown has elapsed
    if combat.cooldown.elapsed_secs() < combat.cooldown.duration().as_secs_f32() {
        return;
    }

    combat.cooldown.reset();

    let position: Vec3 = global_transform.translation();
    let direction = facing_to_vec3(facing);
    let spawn_position = position + direction * 5.0;

    // Get visuals from power type
    let visuals = combat.power_type.visuals(direction);

    spawn_projectile(&mut commands, spawn_position, combat.power_type, &visuals);

    info!("{:?} projectile fired!", combat.power_type);
}

Breaking it down:

  1. Tick the cooldown: combat.cooldown.tick(time.delta()) counts down by the frame time
  2. Check input: Only proceed if Ctrl is pressed
  3. Check cooldown: If elapsed_secs() < duration(), we’re still on cooldown
  4. Reset cooldown: combat.cooldown.reset() starts the timer over
  5. Calculate spawn position: Offset slightly from the player in the facing direction
  6. Get visuals: Power type knows its own visual configuration
  7. Spawn projectile: Create the particle emitters using our particle system!

Now let’s implement the projectile spawning system. This is where we convert player input into visible magical effects.

// Append to src/combat/systems.rs

fn spawn_projectile(
    commands: &mut Commands,
    position: Vec3,
    power_type: PowerType,
    visuals: &PowerVisuals,
) {
    // Primary particles
    let primary_emitter =
        ParticleEmitter::new(0.016, visuals.particles_per_spawn, visuals.primary.clone())
            .one_shot();

    commands.spawn((
        primary_emitter,
        Transform::from_translation(position),
        GlobalTransform::from(Transform::from_translation(position)),
        ProjectileEffect { power_type },
    ));

    // Core particles (if the power has a core)
    if let Some(ref core_config) = visuals.core {
        let core_emitter =
            ParticleEmitter::new(0.016, visuals.core_particles_per_spawn, core_config.clone())
                .one_shot();

        commands.spawn((
            core_emitter,
            Transform::from_translation(position),
            GlobalTransform::from(Transform::from_translation(position)),
            ProjectileEffect { power_type },
        ));
    }
}

How the spawning function works:

  1. Create primary emitter - Spawn the main particle layer with configured count and settings
  2. Mark as one-shot - Emitter spawns once then deactivates (perfect for projectiles)
  3. Position it - Place at the specified position
  4. Add marker component - ProjectileEffect tags this as a projectile for other systems
  5. Spawn core (if exists) - Some powers have a bright center layer

The function takes the visual configuration and converts it into actual particle emitters. Each emitter is its own entity with the ParticleEmitter component.

Now add the helper function to convert facing to direction:

// Append to src/combat/systems.rs

fn facing_to_vec3(facing: &Facing) -> Vec3 {
    match facing {
        Facing::Right => Vec3::X,
        Facing::Left => Vec3::NEG_X,
        Facing::Up => Vec3::Y,
        Facing::Down => Vec3::NEG_Y,
    }
}

Finally, let’s add a debug system to quickly switch between powers for testing:

// Append to src/combat/systems.rs

/// Switch powers with number keys (for testing)
pub fn debug_switch_power(
    input: Res<ButtonInput<KeyCode>>,
    mut player_query: Query<&mut PlayerCombat, With<Player>>,
) {
    let Ok(mut combat) = player_query.single_mut() else {
        return;
    };

    let new_power = if input.just_pressed(KeyCode::Digit1) {
        Some(PowerType::Fire)
    } else if input.just_pressed(KeyCode::Digit2) {
        Some(PowerType::Arcane)
    } else if input.just_pressed(KeyCode::Digit3) {
        Some(PowerType::Shadow)
    } else if input.just_pressed(KeyCode::Digit4) {
        Some(PowerType::Poison)
    } else {
        None
    };

    if let Some(power) = new_power {
        combat.power_type = power;
        info!("Switched to {:?}", power);
    }
}

This lets you press 1-4 to instantly switch powers while playing. Essential for testing visual effects without restarting the game.

Combat Module Plugin

Create src/combat/mod.rs:

// src/combat/mod.rs
mod player_combat;
mod power_type;
mod systems;

pub use player_combat::PlayerCombat;
pub use power_type::{PowerType, PowerVisuals};
pub use systems::{ProjectileEffect, debug_switch_power, handle_power_input};

use bevy::prelude::*;

pub struct CombatPlugin;

impl Plugin for CombatPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Update, (handle_power_input, debug_switch_power));
    }
}

Perfect! Now our combat module is ready to use the particle system.

Putting it all together

Now let’s wire everything together!

First, add rand to your Cargo.toml:

[dependencies]
bevy = "0.18"
bevy_procedural_tilemaps = "0.2.0"
bevy_common_assets = { version = "0.15.0-rc.1", features = ["ron"] }
serde = { version = "1.0", features = ["derive"] }
rand = "0.8"  # Add this line

Open src/main.rs and add the modules:

// src/main.rs - Add to module declarations
mod combat;
mod particles;

Then add the plugins:

// src/main.rs - Add to the plugin chain
.add_plugins(combat::CombatPlugin)
.add_plugins(particles::ParticlesPlugin)

The player needs the PlayerCombat component. Open src/characters/spawn.rs:

// src/characters/spawn.rs - Add import
use crate::combat::PlayerCombat;

Find the initialize_player_character system and add the combat component when inserting the player:

// In the initialize_player_character function, add PlayerCombat to the character entity bundle
commands.entity(entity).insert((
            AnimationController::default(),
            CharacterState::default(), 
            Velocity::default(),       
            Facing::default(),         
            Collider::default(),
            PlayerCombat::default(), // Add this line
            AnimationTimer(Timer::from_seconds(
                DEFAULT_ANIMATION_FRAME_TIME,
                TimerMode::Repeating,
            )),
            character_entry.clone(),
            sprite,
        ));

Run your game:

cargo run

Combat System Demo

Spawn Troubleshooting: There's a small chance the procedural generation places the player on top of a blocking object (tree, rock) at spawn. If you can't move when the game starts, simply restart to generate a new map. This is a quirk of random generation we'll address in future chapters.

Controls:

  • Arrow keys: Move
  • Ctrl: Fire current power
  • 1-4: Switch powers (Fire, Arcane, Shadow, Poison)
Stay Tuned for Chapter 7!
Join our community to get notified when the next chapter drops.

Let's stay connected! Here are some ways