Ebiten Fireworks

1. Project Setup

Download the full code here: fireworks.go or follow the steps below.

Initialize a Go module and fetch the Ebiten v2 library.

go mod init firework-demo
go get github.com/hajimehoshi/ebiten/v2

2. Core Data Structures

The simulation relies on three main structs: Particle (representing both shells and sparks), Firework (managing a shell and its resulting sparks), and App (implementing the ebiten.Game interface).

package main

import (
	"image/color"
	"log"
	"math"
	"math/rand"
	"time"

	"github.com/hajimehoshi/ebiten/v2"
)

const (
	screenWidth  = 800
	screenHeight = 600
	gravity      = 0.08
	maxSparks    = 150
)

type Particle struct {
	x, y    float64
	vx, vy  float64
	life    int
	maxLife int
	color   color.RGBA
	size    float64
}

type Firework struct {
	shell    *Particle
	sparks   []*Particle
	exploded bool
}

type App struct {
	fireworks []*Firework
}

3. Global Texture Initialization

To optimize rendering, generate a single base texture in memory during initialization. All particles will reference this texture and apply matrix transformations for positioning and scaling.

var particleImage *ebiten.Image

func init() {
	particleImage = ebiten.NewImage(3, 3)
	particleImage.Fill(color.White)
	rand.Seed(time.Now().UnixNano()) 
}

4. Physics and Game Logic

The Update() method handles particle kinematics.

Kinematic Rules Applied:

A firework detonates when its vertical velocity reaches apex (vy ≥ -0.5) or it triggers the altitude failsafe (y ≤ 40).

func (a *App) Update() error {
	// Spawn control
	if rand.Float64() < 0.04 && len(a.fireworks) < 8 {
		a.fireworks = append(a.fireworks, newFirework())
	}

	var activeFireworks []*Firework

	for _, fw := range a.fireworks {
		if !fw.exploded {
			// Shell kinematics
			fw.shell.x += fw.shell.vx
			fw.shell.y += fw.shell.vy
			fw.shell.vy += gravity

			// Detonation trigger
			if fw.shell.vy >= -0.5 || fw.shell.y <= 40 {
				fw.explode()
			}
			activeFireworks = append(activeFireworks, fw)
		} else {
			// Spark kinematics
			var activeSparks []*Particle
			for _, s := range fw.sparks {
				s.x += s.vx
				s.y += s.vy
				s.vy += gravity
				s.vx *= 0.98
				s.vy *= 0.98
				s.life--

				if s.life > 0 {
					activeSparks = append(activeSparks, s)
				}
			}
			fw.sparks = activeSparks

			// GC check
			if len(fw.sparks) > 0 {
				activeFireworks = append(activeFireworks, fw)
			}
		}
	}

	a.fireworks = activeFireworks
	return nil
}

5. Spawning and Detonation Mechanics

When a shell explodes, sparks must disperse radially. For a random angle θ and explosion velocity v, the vector components are calculated using vx = v cos(θ) and vy = v sin(θ).

func newFirework() *Firework {
	return &Firework{
		shell: &Particle{
			x:       float64(rand.Intn(screenWidth-100) + 50),
			y:       screenHeight,
			vx:      (rand.Float64() - 0.5) * 2,
			vy:      -(rand.Float64()*3 + 5.5),
			color:   randomBrightColor(),
			size:    1.5,
			life:    1,
			maxLife: 1,
		},
		exploded: false,
	}
}

func (fw *Firework) explode() {
	fw.exploded = true
	numSparks := rand.Intn(maxSparks/2) + maxSparks/2

	for i := 0; i < numSparks; i++ {
		angle := rand.Float64() * 2 * math.Pi
		velocity := rand.Float64() * 6
		life := rand.Intn(40) + 40

		fw.sparks = append(fw.sparks, &Particle{
			x:       fw.shell.x,
			y:       fw.shell.y,
			vx:      math.Cos(angle) * velocity,
			vy:      math.Sin(angle) * velocity,
			life:    life,
			maxLife: life,
			color:   fw.shell.color,
			size:    1.0,
		})
	}
}

6. Rendering Pipeline

The Draw() method iterates through all active particles. Ebiten uses DrawImageOptions to apply geometric and color matrices. The alpha channel is scaled proportionally to the particle's remaining life to create a fade-out effect.

func (a *App) Draw(screen *ebiten.Image) {
	for _, fw := range a.fireworks {
		if !fw.exploded {
			drawParticle(screen, fw.shell)
		} else {
			for _, s := range fw.sparks {
				drawParticle(screen, s)
			}
		}
	}
}

func drawParticle(screen *ebiten.Image, p *Particle) {
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Scale(p.size, p.size)
	op.GeoM.Translate(p.x, p.y)
	
	alpha := float32(p.life) / float32(p.maxLife)
	op.ColorScale.ScaleWithColor(p.color)
	op.ColorScale.ScaleAlpha(alpha)

	screen.DrawImage(particleImage, op)
}

func (a *App) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenWidth, screenHeight
}

7. Helper Functions and Entry Point

Define a color palette and the main function to initialize the window and start the event loop.

func randomBrightColor() color.RGBA {
	colors := []color.RGBA{
		{255, 50, 50, 255},
		{50, 255, 50, 255},
		{50, 150, 255, 255},
		{255, 255, 50, 255},
		{255, 50, 255, 255},
		{50, 255, 255, 255},
		{255, 150, 50, 255},
	}
	return colors[rand.Intn(len(colors))]
}

func main() {
	ebiten.SetWindowSize(screenWidth, screenHeight)
	ebiten.SetWindowTitle("Ebiten Fireworks")
	
	app := &App{}
	
	if err := ebiten.RunGame(app); err != nil {
		log.Fatal(err)
	}
}

More Go + Ebiten tutorials:

Animated plasma effect

Starfield flythrough