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:
- A way to represent an individual particle. What information does one tiny dot need?
- 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 usefloat64for 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 randomspeed. - Calculates the
dxanddyvelocity components using basic trigonometry (math.Cosandmath.Sin) based on the angle and speed. This makes particles shoot out in different random directions. - Creates a new
Particlestruct with the starting position (x,y), calculated velocity, initiallife(set tomaxLife, like 25 frames), and the providedpcolor. - Uses
appendto add this newly createdParticleto theps.particlesslice.
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:
- Updates its position (
p.x,p.y) by adding its velocity (p.dx,p.dy). - Decreases its
lifecount by 1. - Checks if
p.lifeis still greater than 0. If so, the particle is still "alive" and is added to thealiveslice. - After checking all particles, the
ps.particlesslice is replaced by thealiveslice. This effectively removes any particles whoselifereached 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:
- Calculates an
alphavalue (between 0 and 255) based on the particle's remaininglifecompared to itsmaxLife. A particle with full life hasalpha=255(fully opaque), and a particle withlife=1hasalphaclose to 0 (almost fully transparent). - Takes the particle's original
colorand creates a newcolor.RGBAusing the original R, G, B values and the calculated fadingalpha. - Uses
vector.DrawFilledCirclefrom Ebitengine to draw a small filled circle (with radius 2 in this case) at the particle'sx,yposition using the calculatedcolwith 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.