diff --git a/examples/smoke/README.md b/examples/smoke/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..ccaf2e0c0dad3beaa630922094def3a14bf51025
--- /dev/null
+++ b/examples/smoke/README.md
@@ -0,0 +1,8 @@
+# Smoke
+
+This example implements a smoke particle effect using sprites. It uses a spritesheet with a CSV
+description.
+
+The art in the spritesheet comes from [Kenney](https://kenney.nl/).
+
+![Screenshot](screenshot.png)
\ No newline at end of file
diff --git a/examples/smoke/blackSmoke.csv b/examples/smoke/blackSmoke.csv
new file mode 100644
index 0000000000000000000000000000000000000000..7870d7b08d02ed609cc9d40222f444421f50db96
--- /dev/null
+++ b/examples/smoke/blackSmoke.csv
@@ -0,0 +1,25 @@
+1543,1146,362,336
+396,0,398,364
+761,1535,386,342
+795,794,351,367
+394,1163,386,364
+1120,1163,377,348
+795,0,368,407
+0,0,395,397
+1164,0,378,415
+781,1163,338,360
+1543,0,372,370
+1148,1535,393,327
+387,1535,373,364
+396,365,371,388
+0,758,378,404
+379,758,378,371
+1543,774,360,371
+1543,1483,350,398
+0,398,382,359
+1164,416,356,382
+1164,799,369,350
+0,1535,386,394
+795,408,366,385
+1543,371,367,402
+0,1163,393,371
\ No newline at end of file
diff --git a/examples/smoke/blackSmoke.png b/examples/smoke/blackSmoke.png
new file mode 100644
index 0000000000000000000000000000000000000000..c5ebbb8d99561424d90889d7fb7c29b3ed630c06
Binary files /dev/null and b/examples/smoke/blackSmoke.png differ
diff --git a/examples/smoke/main.go b/examples/smoke/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..78dea1a25ab400bbc56adea7325f607eb0dfd28c
--- /dev/null
+++ b/examples/smoke/main.go
@@ -0,0 +1,240 @@
+package main
+
+import (
+	"container/list"
+	"encoding/csv"
+	"fmt"
+	"image"
+	"io"
+	"math"
+	"math/rand"
+	"os"
+	"strconv"
+	"time"
+
+	_ "image/png"
+
+	"github.com/faiface/pixel"
+	"github.com/faiface/pixel/pixelgl"
+	"golang.org/x/image/colornames"
+)
+
+type particle struct {
+	Sprite     *pixel.Sprite
+	Pos        pixel.Vec
+	Rot, Scale float64
+	Mask       pixel.RGBA
+	Data       interface{}
+}
+
+type particles struct {
+	Generate            func() *particle
+	Update              func(dt float64, p *particle) bool
+	SpawnAvg, SpawnDist float64
+
+	parts     list.List
+	spawnTime float64
+}
+
+func (p *particles) UpdateAll(dt float64) {
+	p.spawnTime -= dt
+	for p.spawnTime <= 0 {
+		p.parts.PushFront(p.Generate())
+		p.spawnTime += math.Max(0, p.SpawnAvg+rand.NormFloat64()*p.SpawnDist)
+	}
+
+	for e := p.parts.Front(); e != nil; e = e.Next() {
+		part := e.Value.(*particle)
+		if !p.Update(dt, part) {
+			defer p.parts.Remove(e)
+		}
+	}
+}
+
+func (p *particles) DrawAll(t pixel.Target) {
+	for e := p.parts.Front(); e != nil; e = e.Next() {
+		part := e.Value.(*particle)
+
+		part.Sprite.SetMatrix(pixel.IM.
+			Scaled(0, part.Scale).
+			Rotated(0, part.Rot).
+			Moved(part.Pos),
+		)
+		part.Sprite.SetColorMask(part.Mask)
+		part.Sprite.Draw(t)
+	}
+}
+
+type smokeData struct {
+	Vel  pixel.Vec
+	Time float64
+	Life float64
+}
+
+type smokeSystem struct {
+	Sheet pixel.Picture
+	Rects []pixel.Rect
+	Orig  pixel.Vec
+
+	VelBasis []pixel.Vec
+	VelDist  float64
+
+	LifeAvg, LifeDist float64
+}
+
+func (ss *smokeSystem) Generate() *particle {
+	sd := new(smokeData)
+	for _, base := range ss.VelBasis {
+		c := math.Max(0, 1+rand.NormFloat64()*ss.VelDist)
+		sd.Vel += base.Scaled(c)
+	}
+	sd.Vel = sd.Vel.Scaled(1 / float64(len(ss.VelBasis)))
+	sd.Life = math.Max(0, ss.LifeAvg+rand.NormFloat64()*ss.LifeDist)
+
+	p := new(particle)
+	p.Data = sd
+
+	p.Pos = ss.Orig
+	p.Scale = 1
+	p.Mask = pixel.Alpha(1)
+	p.Sprite = pixel.NewSprite(ss.Sheet, ss.Rects[rand.Intn(len(ss.Rects))])
+
+	return p
+}
+
+func (ss *smokeSystem) Update(dt float64, p *particle) bool {
+	sd := p.Data.(*smokeData)
+	sd.Time += dt
+
+	frac := sd.Time / sd.Life
+
+	p.Pos += sd.Vel.Scaled(dt)
+	p.Scale = 0.5 + frac*1.5
+
+	const (
+		fadeIn  = 0.2
+		fadeOut = 0.4
+	)
+	if frac < fadeIn {
+		p.Mask = pixel.Alpha(math.Pow(frac/fadeIn, 0.75))
+	} else if frac >= fadeOut {
+		p.Mask = pixel.Alpha(math.Pow(1-(frac-fadeOut)/(1-fadeOut), 1.5))
+	} else {
+		p.Mask = pixel.Alpha(1)
+	}
+
+	return sd.Time < sd.Life
+}
+
+func loadSpriteSheet(sheetPath, descriptionPath string) (sheet pixel.Picture, rects []pixel.Rect, err error) {
+	sheetFile, err := os.Open(sheetPath)
+	if err != nil {
+		return nil, nil, err
+	}
+	defer sheetFile.Close()
+
+	sheetImg, _, err := image.Decode(sheetFile)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	sheet = pixel.PictureDataFromImage(sheetImg)
+
+	descriptionFile, err := os.Open(descriptionPath)
+	if err != nil {
+		return nil, nil, err
+	}
+	defer descriptionFile.Close()
+
+	description := csv.NewReader(descriptionFile)
+	for {
+		record, err := description.Read()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, nil, err
+		}
+
+		x, _ := strconv.ParseFloat(record[0], 64)
+		y, _ := strconv.ParseFloat(record[1], 64)
+		w, _ := strconv.ParseFloat(record[2], 64)
+		h, _ := strconv.ParseFloat(record[3], 64)
+
+		y = sheet.Bounds().H() - y - h
+
+		rects = append(rects, pixel.R(x, y, x+w, y+h))
+	}
+
+	return sheet, rects, nil
+}
+
+func run() {
+	sheet, rects, err := loadSpriteSheet("blackSmoke.png", "blackSmoke.csv")
+	if err != nil {
+		panic(err)
+	}
+
+	cfg := pixelgl.WindowConfig{
+		Title:     "Smoke",
+		Bounds:    pixel.R(0, 0, 1024, 768),
+		Resizable: true,
+		VSync:     true,
+	}
+	win, err := pixelgl.NewWindow(cfg)
+	if err != nil {
+		panic(err)
+	}
+
+	ss := &smokeSystem{
+		Rects:    rects,
+		Orig:     0,
+		VelBasis: []pixel.Vec{pixel.V(-100, 100), pixel.V(100, 100), pixel.V(0, 100)},
+		VelDist:  0.1,
+		LifeAvg:  7,
+		LifeDist: 0.5,
+	}
+
+	p := &particles{
+		Generate:  ss.Generate,
+		Update:    ss.Update,
+		SpawnAvg:  0.3,
+		SpawnDist: 0.1,
+	}
+
+	batch := pixel.NewBatch(&pixel.TrianglesData{}, sheet)
+
+	var (
+		second = time.Tick(time.Second)
+		frames = 0
+	)
+
+	last := time.Now()
+	for !win.Closed() {
+		dt := time.Since(last).Seconds()
+		last = time.Now()
+
+		p.UpdateAll(dt)
+
+		win.Clear(colornames.Aliceblue)
+		win.SetMatrix(pixel.IM.Moved(win.Bounds().Center() - pixel.Y(win.Bounds().H()/2)))
+
+		batch.Clear()
+		p.DrawAll(batch)
+		batch.Draw(win)
+
+		win.Update()
+
+		frames++
+		select {
+		case <-second:
+			win.SetTitle(fmt.Sprintf("%s | FPS: %d", cfg.Title, frames))
+			frames = 0
+		default:
+		}
+	}
+}
+
+func main() {
+	pixelgl.Run(run)
+}
diff --git a/examples/smoke/screenshot.png b/examples/smoke/screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..6cf005641e06f797bd9bad34b744fc4047f507a4
Binary files /dev/null and b/examples/smoke/screenshot.png differ