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
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
}
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())
}
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
}
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,
})
}
}
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
}
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)
}
}