Game Engine Zero Vol 1 · Three Arcade Games
ch 06 / 24
Chapter 06

Sound

Playing a sound

This chapter plays a WAV file when Space is pressed. It also plays a second sound on a timer, once every sixty ticks.

Ebitengine's audio package provides the context and players. A program opens one context, decodes each sound once, makes one player for that sound and plays it by rewinding the player and starting it. The worked failure shows what Ebitengine reports when the program tries to make a new player from the same stream on each press.

Loading one player

Download the two sounds into assets/, beside the sheet. The first program plays one of them, on Space, and lights a square for ten ticks so that the tick it played on can be seen as well as heard.

▣ Build · stage 1 — a beep on Space
// cmd/beep/main.go — create
package main

import (
	"bytes"
	"image/color"
	"log"
	"math"
	"os"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/audio"
	"github.com/hajimehoshi/ebiten/v2/audio/wav"
	"github.com/hajimehoshi/ebiten/v2/inpututil"
	"github.com/hajimehoshi/ebiten/v2/text/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"
	"golang.org/x/image/font/gofont/goregular"
)

const (
	screenW    = 320
	screenH    = 180
	sampleRate = 22050 // samples a second: the rate the sounds were written at
	litFor     = 10    // ticks a light stays lit after its sound plays
)

var (
	courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}
	lineColor  = color.RGBA{R: 232, G: 232, B: 232, A: 255}
	dimColor   = color.RGBA{R: 60, G: 66, B: 80, A: 255}
)

func newFace(size float64) *text.GoTextFace {
	src, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
	if err != nil {
		log.Fatal(err)
	}
	return &text.GoTextFace{Source: src, Size: size}
}

func drawCentred(dst *ebiten.Image, s string, face *text.GoTextFace, x, y float64) {
	w, _ := text.Measure(s, face, 0)
	op := &text.DrawOptions{}
	op.GeoM.Translate(math.Floor(x-w/2), y)
	op.ColorScale.ScaleWithColor(lineColor)
	text.Draw(dst, s, face, op)
}

// keys is what the player is doing this tick.
type keys struct {
	hit bool // Space, on the tick it went down
}

func readKeys() keys {
	return keys{hit: inpututil.IsKeyJustPressed(ebiten.KeySpace)}
}

type Game struct {
	face   *text.GoTextFace
	hit    *audio.Player // the hit sound, decoded once
	hitLit int           // ticks the light has left
}

// step advances the game one tick: a hit on Space.
func (g *Game) step(k keys) {
	if k.hit {
		g.hit.Rewind()
		g.hit.Play()
		g.hitLit = litFor
	}
	if g.hitLit > 0 {
		g.hitLit--
	}
}

func (g *Game) Update() error {
	g.step(readKeys())
	return nil
}

// drawLight draws a square that is lit while its sound is fresh.
func drawLight(screen *ebiten.Image, x float64, lit int, label string, face *text.GoTextFace) {
	c := dimColor
	if lit > 0 {
		c = lineColor
	}
	vector.FillRect(screen, float32(x)-16, 60, 32, 32, c, false)
	drawCentred(screen, label, face, x, 100)
}

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(courtColor)
	drawLight(screen, 100, g.hitLit, "SPACE", g.face)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Beep")
	ebiten.SetTPS(60)
	ctx := audio.NewContext(sampleRate)
	data, err := os.ReadFile("assets/hit.wav")
	if err != nil {
		log.Fatal(err)
	}
	stream, err := wav.DecodeWithSampleRate(sampleRate, bytes.NewReader(data))
	if err != nil {
		log.Fatal(err)
	}
	hit, err := ctx.NewPlayer(stream)
	if err != nil {
		log.Fatal(err)
	}
	g := &Game{face: newFace(12), hit: hit}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}
go mod tidy
go mod vendor
cat go.mod
go vet ./...
go run ./cmd/beep
$ go mod tidy
$ go mod vendor
$ cat go.mod
module gez

go 1.26

require (
	github.com/hajimehoshi/ebiten/v2 v2.9.11
	golang.org/x/image v0.43.0
)

require (
	github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
	github.com/ebitengine/hideconsole v1.0.0 // indirect
	github.com/ebitengine/oto/v3 v3.4.1 // indirect
	github.com/ebitengine/purego v0.9.0 // indirect
	github.com/go-text/typesetting v0.3.0 // indirect
	github.com/jezek/xgb v1.1.1 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	golang.org/x/sync v0.21.0 // indirect
	golang.org/x/sys v0.44.0 // indirect
	golang.org/x/text v0.38.0 // indirect
)
$ go vet ./...
A dark window with one bright square in its left half and the word SPACE under it.
The window after stage 1, three ticks after a tap of Space: the light is lit, and the beep has just sounded.

Tap Space and the beep sounds. The square lights for a sixth of a second so you can see the tick that played it.

Tap Space twice quickly and the sound starts twice. The second tap cuts the first beep short because Rewind moves the player back to the first sample before Play starts it. A player at the end of its samples must be rewound before it can play again.

The audio packages bring in oto, the module Ebitengine uses to talk to the sound device. go mod tidy and go mod vendor add and vendor it.

main opens the context at 22,050 samples a second, the rate of these sounds. It reads the WAV file into memory. wav.DecodeWithSampleRate turns the bytes into samples the context can read, and ctx.NewPlayer makes the player kept in the game.

⚙ Tool — an audio context and a player

audio.NewContext(rate) opens the machine's sound device at a sample rate; there is one per program, and the library reports a device it cannot open as the error RunGame returns. wav.DecodeWithSampleRate(rate, r) reads a WAV from a reader and returns a stream in the context's format. ctx.NewPlayer(stream) makes a player; Rewind puts it back to the start, Play starts it, and a player that is already playing when Play is called carries on. The reference is pkg.go.dev/github.com/hajimehoshi/ebiten/v2/audio.

One context, two players, and the stream the device gets Two boxes on the left, hit.wav and point.wav, each decoded into a player drawn as a bar with a position marker. Arrows lead from both players into one box labelled the audio context, which mixes them, and from it one arrow to the sound device labelled 22,050 samples a second, always. A note says Play moves a marker, Rewind puts it back to the start, and a second player on one stream is refused. FILES, PLAYERS, THE CONTEXT, THE DEVICE hit.wav player, position point.wav player, at the start the context adds them up the device 22,050 a second Play moves a marker on; Rewind puts it back to the start; one player per file
Figure 6.1 — two files decoded once into two players, one context adding up whatever is playing, and the device consuming a steady stream.
⚠ Worked failure — a new player on every press

The player is used in step. Making it there from the decoded stream looks tidy, but it makes a second player from the same stream:

type Game struct {
	face   *text.GoTextFace
	ctx    *audio.Context
	stream *wav.Stream // the hit sound, decoded once
	hitLit int         // ticks the light has left
}

// step advances the game one tick: a hit on Space.
func (g *Game) step(k keys) {
	if k.hit {
		p, err := g.ctx.NewPlayer(g.stream)
		if err != nil {
			log.Fatal(err)
		}
		p.Play()
		g.hitLit = litFor
	}
	if g.hitLit > 0 {
		g.hitLit--
	}
}

with main keeping the context and the stream in the game instead of a player. The first tap beeps. The second tap, a quarter of a second later, ends the program:

$ go run ./cmd/beep
audio: audio error: audio: the same source must not be used by multiple Player objects

That line follows the date and time printed by log.Fatal. go run then reports exit status 1.

The message names the problem. Two players were made from one stream. A stream is a position in the decoded samples, so two players would fight over where that position is. Ebitengine refuses the second player. Keep one player and rewind it.

Playing on a timer

The second sound plays once a second. At sixty ticks a second, that means every sixtieth tick. The counter that decides it lives in Update.

Move the loading code into internal/sound. The package opens the context when the first sound loads and hides the rewind inside Play.

▣ Build · stage 2 — the sound package, and a point every second
// internal/sound/sound.go — create
// Package sound plays short WAV files: one audio context for the whole
// program, one player for each sound, made once, and started from the
// beginning on the tick a game says. Every game in this volume plays its
// sounds with it.
package sound

import (
	"bytes"
	"os"

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

// SampleRate is the rate the volume's sounds were written at, and the rate
// the audio context is opened at.
const SampleRate = 22050

// context is the program's one audio context, opened by the first Load.
// A program can open only one, and every player lives in it.
var context *audio.Context

// Sound is one sound a game can play.
type Sound struct {
	player *audio.Player
}

// Load reads the WAV file at path, decodes it, and makes a player for it
// in the program's audio context, opening the context if this is the first
// sound loaded.
func Load(path string) (*Sound, error) {
	if context == nil {
		context = audio.NewContext(SampleRate)
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	stream, err := wav.DecodeWithSampleRate(SampleRate, bytes.NewReader(data))
	if err != nil {
		return nil, err
	}
	player, err := context.NewPlayer(stream)
	if err != nil {
		return nil, err
	}
	return &Sound{player: player}, nil
}

// Play plays the sound from its start. A sound still playing starts again.
func (s *Sound) Play() {
	s.player.Rewind()
	s.player.Play()
}
// cmd/beep/main.go — extend
import (
	"bytes"
	"image/color"
	"log"
	"math"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/inpututil"
	"github.com/hajimehoshi/ebiten/v2/text/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"
	"golang.org/x/image/font/gofont/goregular"

	"gez/internal/sound"
)

const (
	screenW = 320
	screenH = 180
)

type Game struct {
	face       *text.GoTextFace
	hit, point *sound.Sound
	tick       int
	hitLit     int // ticks the hit light has left
	pointLit   int
}

// step advances the game one tick: a hit on Space, a point every second.
func (g *Game) step(k keys) {
	g.tick++
	if k.hit {
		g.hit.Play()
		g.hitLit = litFor
	}
	if g.tick%pointEvery == 0 {
		g.point.Play()
		g.pointLit = litFor
	}
	if g.hitLit > 0 {
		g.hitLit--
	}
	if g.pointLit > 0 {
		g.pointLit--
	}
}

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(courtColor)
	drawLight(screen, 100, g.hitLit, "SPACE", g.face)
	drawLight(screen, 220, g.pointLit, "EVERY SECOND", g.face)
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Beep")
	ebiten.SetTPS(60)
	hit, err := sound.Load("assets/hit.wav")
	if err != nil {
		log.Fatal(err)
	}
	point, err := sound.Load("assets/point.wav")
	if err != nil {
		log.Fatal(err)
	}
	g := &Game{face: newFace(12), hit: hit, point: point}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}

// A light stays lit for ten ticks after its sound plays; a point sounds
// every sixty ticks.
const (
	litFor     = 10
	pointEvery = 60
)
go vet ./...
go run ./cmd/beep
The window with two squares: a dim one on the left labelled SPACE and a bright one on the right labelled EVERY SECOND.
The window on the sixtieth tick after stage 2 starts: the point light lit and its lower tone sounding, the hit light dim.

The lower tone sounds once a second and the right light blinks with it. Space still plays the hit sound on the left. If a tap lands near a second boundary, both sounds play because the context mixes every player that is playing.

The extend listing removes os, audio and wav from cmd/beep, because internal/sound now does that work. It also moves the sample rate into the package and keeps the light and timer constants beside the code that uses them.

sound.Load reads a file, decodes it and makes one player. The context lives in a package variable, so the program loads a sound in one call and never handles the context directly.

Mixing players

The sound device receives a steady stream of samples. The context supplies that stream and adds together the samples from every player that is playing. Silence is zeros.

A player is a position in one decoded sound. Playing advances the position, reaching the end stops it, and rewinding moves it back to the start. Decoding once and rewinding keeps sound playback tied to the tick that caused it.

Checkpoint

✓ Checkpoint — what you can now do
  • Open the program's one audio context at the sounds' sample rate and say what a second NewContext does.
  • Read a WAV file, decode it into a stream, and make one player from it, in the order those three happen.
  • Play a sound on the tick a key goes down and on every sixtieth tick, with the counter where chapter 1 put it.
  • Say why a new player per press ends the program, from the error's own words, and why rewinding the one player is the fix.
  • Load a sound through internal/sound in two lines, and know what the package keeps that the game never sees.
⚡ Exercises — try first, then reveal
Exercise 1 — a faster beat. Change pointEvery to 20, run it, and count the tones in ten seconds.

Thirty: three a second, because sixty ticks a second divided by twenty ticks a tone is three. The tone is 180 milliseconds long and the gap between starts is 333, so each finishes before the next begins and none is cut short.

Exercise 2 — a quieter point. Give Sound a SetVolume(v float64) method that passes v to the player, and play the point at a fifth of its volume.

func (s *Sound) SetVolume(v float64) { s.player.SetVolume(v) }, and point.SetVolume(0.2) in main after the load. The volume is a scale from 0 to 1 on every sample the player contributes to the mix, and it stays set across rewinds, so the tone is quiet every second.

Exercise 3 — the two-key chord. Play the point on S as well as on the timer, and tap S and Space together.

A second field in keys read with IsKeyJustPressed(ebiten.KeyS), and g.point.Play() with the point light in step when it is set. Both tones sound at once, the higher one ending after sixty milliseconds and the lower one carrying on, which is the mixer adding two players' samples in the same stream.