Chapter 6: Particle Effects System

July 14, 2025 ยท View on GitHub

Welcome back! In the previous chapter, we learned how the game uses a SoundManager to add audio feedback to events, making the game feel more responsive. Sounds are great, but visual flair is just as important!

Have you ever noticed those little bursts of colored dots in games when something special happens? Like sparks when something hits, or dust when something lands? These are often created by a Particle Effects System. Particles are tiny elements that are spawned, move around, and usually fade away over a short time, adding dynamic visual details without needing complex animations.

In our Snake game, we use a particle system to add visual excitement to key moments:

  • When the snake eats food, a burst of yellow/orange particles appears at the food's location.
  • When the game ends due to a collision, a burst of red particles appears at the snake's head.
  • When food randomly disappears, green particles appear.

This chapter will introduce you to the ParticleSystem in our project and how it brings these visual effects to life.

What is a Particle Effect?

Think of a particle effect like a mini fireworks display or a puff of smoke. It's made up of lots of individual, simple elements (the "particles") that are all born at roughly the same time and place, then follow their own simple rules (like moving in a direction and fading).

In our code, we need two main things:

  1. A way to represent an individual particle. What information does one tiny dot need?
  2. A way to manage all the particles currently active in the game. How do we create them, update them every frame, and draw them?

The Individual Particle: The Particle Struct

Let's look at how we define a single particle in particles.go:

// File: particles.go

type Particle struct {
	x, y   float64       // Position (using float64 for smoother movement)
	dx, dy float64       // Velocity (how much to move per frame)
	life   int           // How many frames this particle has left to live
	color  color.Color // The particle's starting color
}

Each Particle struct is simple. It just needs:

  • Its position (x, y). We use float64 for potentially sub-pixel movement, even if we draw them on integer pixels.
  • Its velocity (dx, dy). This determines how far it moves each frame horizontally and vertically.
  • Its remaining life. We'll make particles disappear after a certain number of game frames.
  • Its color. This is the color the particle starts with. We'll make it fade later.

Managing the Particles: The ParticleSystem Struct

Just having individual Particle structs isn't enough. We need something to hold onto all the currently active particles and manage their lifecycle. This is the job of the ParticleSystem, also in particles.go:

// File: particles.go

type ParticleSystem struct {
	particles []Particle // A slice (list) holding all the active particles
}

This struct is even simpler! It just contains a slice ([]) called particles. This slice is where we'll store every single Particle that's currently alive and needs to be updated or drawn.

The NewParticleSystem() function is used to create an instance of this manager, usually when the game starts:

// File: particles.go

func NewParticleSystem() *ParticleSystem {
	return &ParticleSystem{
		particles: make([]Particle, 0, 100), // Create an empty slice with capacity for 100
	}
}

This function simply initializes the ParticleSystem with an empty slice ready to hold particles. In main.go, the Game struct has a field particles *ParticleSystem, and NewGame() calls NewParticleSystem() to set it up, just like it does for SoundManager.

Making Particles Appear: The Spawn() Method

The game needs a way to tell the ParticleSystem to create new particles at a specific location. This is done using the ParticleSystem.Spawn() method:

// File: particles.go (simplified)

func (ps *ParticleSystem) Spawn(x, y float64, pcolor color.Color) {
	// Create a burst of particles (e.g., 10)
	for i := 0; i < 10; i++ {
		// ... calculate random angle and speed ...
		angle := rand.Float64() * 2 * math.Pi // Angle between 0 and 2*Pi
		speed := 2 + rand.Float64()*2         // Speed between 2.0 and 4.0

		// Create a new Particle instance
		particle := Particle{
			x:     x, // Start at the given location
			y:     y,
			dx:    math.Cos(angle) * speed, // Calculate velocity based on angle and speed
			dy:    math.Sin(angle) * speed,
			life:  maxLife, // Set starting life (maxLife is defined elsewhere, e.g., 25)
			color: pcolor,  // Use the provided color
		}
		// Add the new particle to the system's list
		ps.particles = append(ps.particles, particle)
	}
}

The Spawn() method takes the x and y coordinates where the burst should start, and the pcolor for the particles. Inside the method, it runs a loop (in this case, 10 times) to create multiple particles for the burst. For each particle, it:

  • Picks a random direction (angle) and a random speed.
  • Calculates the dx and dy velocity components using basic trigonometry (math.Cos and math.Sin) based on the angle and speed. This makes particles shoot out in different random directions.
  • Creates a new Particle struct with the starting position (x, y), calculated velocity, initial life (set to maxLife, like 25 frames), and the provided pcolor.
  • Uses append to add this newly created Particle to the ps.particles slice.

Now, let's see where Spawn() is called in main.go when specific game events happen:

// File: main.go (simplified Game.checkFoodCollision)

func (g *Game) checkFoodCollision() bool {
	// ... check if snake head hits food ...
	if head == *g.food {
		// Food was eaten!
		// Call Spawn at the food's screen coordinates
		g.particles.Spawn(float64(g.food.x*gridSize+gridSize/2), float64(g.food.y*gridSize+gridSize/2),
			color.RGBA{255, 220, 100, 255}) // Yellow/orange color
		// ... remove food, grow snake, update score ...
		return true
	}
	return false
}
// File: main.go (simplified Game.Update collision check)

func (g *Game) Update() error {
	// ... snake movement and collision check ...
	if g.checkCollision(g.snake[0]) {
		// Collision detected (game over)!
		// Call Spawn at the snake head's screen coordinates
		g.particles.Spawn(float64(g.snake[0].x*gridSize+gridSize/2), float64(g.snake[0].y*gridSize+gridSize/2),
			color.RGBA{255, 0, 0, 255}) // Red color
		// ... play sound, set g.gameOver = true ...
	} // ... else if checkFoodCollision() ...
	// ... random food disappearance logic ...
	if rand.IntN(1000) < 10 && g.food != nil {
		// Food randomly disappeared!
		// Call Spawn at the food's screen coordinates
		g.particles.Spawn(float64(g.food.x*gridSize+gridSize/2), float64(g.food.y*gridSize+gridSize/2), GREEN) // Green color
		// ... remove food ...
	}
	// ... rest of update logic ...
	return nil
}

These snippets show exactly how Game.Update() and Game.checkFoodCollision() trigger particle effects. They get the relevant grid coordinates (food or snake head), convert them to screen pixel coordinates (multiplying by gridSize and adding gridSize/2 to get the center), and pass that location along with a specific color to g.particles.Spawn().

Updating Particles: The Update() Method

Once particles are spawned, they need to move and age every single frame. This is handled by the ParticleSystem.Update() method:

// File: particles.go

func (ps *ParticleSystem) Update() {
	var alive []Particle // Create a new slice to hold particles that survive this frame

	// Loop through all currently active particles
	for _, p := range ps.particles {
		// Move the particle based on its velocity
		p.x += p.dx
		p.y += p.dy

		// Decrease the particle's life count
		p.life--

		// If the particle still has life left, keep it for the next frame
		if p.life > 0 {
			alive = append(alive, p)
		}
	}
	// Replace the old slice of particles with the new one containing only living particles
	ps.particles = alive
}

This Update() method is called once per game frame by Game.Update(). For every particle currently in the ps.particles slice, it:

  1. Updates its position (p.x, p.y) by adding its velocity (p.dx, p.dy).
  2. Decreases its life count by 1.
  3. Checks if p.life is still greater than 0. If so, the particle is still "alive" and is added to the alive slice.
  4. After checking all particles, the ps.particles slice is replaced by the alive slice. This effectively removes any particles whose life reached 0 or below.

This simple logic makes particles move away from their spawn point and disappear naturally over time.

Drawing Particles: The Draw() Method

Finally, we need to make the particles visible on the screen every frame. This is the job of the ParticleSystem.Draw() method:

// File: particles.go (simplified)

func (ps *ParticleSystem) Draw(screen *ebiten.Image) {
	// Loop through all currently active particles
	for _, p := range ps.particles {
		// Calculate the alpha (transparency) based on remaining life
		// Particles fade out as life decreases
		alpha := uint8(float64(p.life) / float64(maxLife) * 255)

		// Get the original color and apply the calculated alpha
		r, g, b, _ := p.color.RGBA() // Get 16-bit color components
		col := color.RGBA{
			R: uint8(r >> 8), // Convert to 8-bit
			G: uint8(g >> 8),
			B: uint8(b >> 8),
			A: alpha, // Use the fading alpha
		}

		// Draw the particle as a small filled circle
		vector.DrawFilledCircle(screen, float32(p.x), float32(p.y), 2, col, true)
	}
}

This Draw() method is called once per game frame by Game.Draw(). For every particle in the ps.particles slice, it:

  1. Calculates an alpha value (between 0 and 255) based on the particle's remaining life compared to its maxLife. A particle with full life has alpha=255 (fully opaque), and a particle with life=1 has alpha close to 0 (almost fully transparent).
  2. Takes the particle's original color and creates a new color.RGBA using the original R, G, B values and the calculated fading alpha.
  3. Uses vector.DrawFilledCircle from Ebitengine to draw a small filled circle (with radius 2 in this case) at the particle's x, y position using the calculated col with the fading alpha.

This makes the particles appear as small, colored dots that move and fade away as they get closer to the end of their life.

Particle System in the Game Loop

Let's see how the ParticleSystem fits into the main Game Loop (Chapter 1) alongside the other components we've discussed.

sequenceDiagram
    participant E as Ebitengine Engine
    participant G as Our Game (Game struct)
    participant PS as ParticleSystem

    E->>G: Call Update()
    G->>PS: Call Update()
    Note over PS: Move existing particles,<br/>Reduce life,<br/>Remove dead particles
    alt Game Logic (e.g., Food eaten or Game Over)
        G->>PS: Call Spawn(x, y, color)
        Note over PS: Create new Particle(s),<br/>Add to list
    end
    Note over G: Handle snake, food, buttons, etc.

    E->>G: Call Draw(screen)
    Note over G: Draw border, score, buttons
    G->>PS: Call Draw(screen)
    Note over PS: Loop through active particles,<br/>Calculate fading color,<br/>Draw each particle
    Note over G: Draw snake, food, text

    E-->>E: Display screen to player
    Note over E: Repeat this cycle

The ParticleSystem's Update method is called first in Game.Update() to make sure all existing particles move and age before anything else changes state. Then, during the game logic (like checking for food collision or game over), the Spawn method is called if a particle-generating event occurs. In the Draw phase, Game.Draw() calls ParticleSystem.Draw() after drawing static elements but before drawing the main game elements, or at any point it's suitable in the drawing order. This ensures the particles are rendered on the screen based on their updated positions and fade states.

In Summary

The Particle Effects System in our game is managed by the ParticleSystem struct, which holds a list of individual Particle structs. Each Particle tracks its position, velocity, life, and initial color. Key game events (eating food, game over, random food disappearance) trigger the ParticleSystem.Spawn() method, creating bursts of new particles at the event's location. The ParticleSystem.Update() method is called every frame by Game.Update() to move particles and reduce their life. The ParticleSystem.Draw() method is called every frame by Game.Draw() to render the remaining particles on the screen, calculating their transparency based on their life to create a fading effect. This system adds simple yet effective visual feedback to the player's actions and the game's state changes.

With particle effects added, our Snake game now has visual polish, interactive controls, and sound feedback! We've covered all the core systems that make the game work.



References: [1], [2]