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

Text and the HUD

Drawing text

This chapter draws a score and a pause message. The score uses the sprite sheet's block digits. The words use Go Regular, a TrueType font carried inside the program.

A number's width comes from its digit count. A word's width comes from the font. In both cases, centring means subtracting half the width from the middle column and drawing at a whole-pixel left edge. The pause overlay then shows the draw-order rule: later calls paint over earlier calls.

Drawing sheet digits

Put the digit font in internal/digits. Go allows only code inside the gez module to import an internal package, so the package is private to this project. It loads the sheet and cuts the ten glyphs out of row 24.

▣ Build · stage 1 — the digit font, and two scores drawn with it
// internal/digits/digits.go — create
// Package digits draws numbers with the sprite sheet's digit font: ten
// glyphs, three pixels wide and five tall, on row 24 of the sheet. Every
// game in this volume draws its score with it.
package digits

import (
	"image"
	"image/png"
	"os"

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

// Font is the ten digits, cut from the sheet.
type Font struct {
	glyph [10]*ebiten.Image
}

// The glyphs' size on the sheet, and the gap between two digits, in the
// sheet's pixels; a scale multiplies all three.
const (
	GlyphW = 3
	GlyphH = 5
	Gap    = 1
)

// Load reads the sheet at path and cuts the ten digits out of it.
func Load(path string) (*Font, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	img, err := png.Decode(f)
	if err != nil {
		return nil, err
	}
	sheet := ebiten.NewImageFromImage(img)
	var font Font
	for i := range font.glyph {
		r := image.Rect(GlyphW*i, 24, GlyphW*i+GlyphW, 24+GlyphH)
		font.glyph[i] = sheet.SubImage(r).(*ebiten.Image)
	}
	return &font, nil
}

// Width is how many pixels wide n is when drawn at scale.
func Width(n, scale int) int {
	count := 1
	for n >= 10 {
		n /= 10
		count++
	}
	return scale * (count*GlyphW + (count-1)*Gap)
}

// Draw draws n at scale with its left edge on column x and its top on row y.
func (f *Font) Draw(dst *ebiten.Image, n int, x, y float64, scale int) {
	var glyphs []int
	for n >= 10 {
		glyphs = append([]int{n % 10}, glyphs...)
		n /= 10
	}
	glyphs = append([]int{n}, glyphs...)
	step := float64(scale * (GlyphW + Gap))
	for i, d := range glyphs {
		op := &ebiten.DrawImageOptions{}
		op.GeoM.Scale(float64(scale), float64(scale))
		op.GeoM.Translate(x+float64(i)*step, y)
		dst.DrawImage(f.glyph[d], op)
	}
}

// DrawCentred draws n at scale with its middle on column x and its top on
// row y, rounding the left edge down to a whole pixel.
func (f *Font) DrawCentred(dst *ebiten.Image, n int, x, y float64, scale int) {
	left := float64(int(x) - Width(n, scale)/2)
	f.Draw(dst, n, left, y, scale)
}
// cmd/score/main.go — create
package main

import (
	"image/color"
	"log"

	"github.com/hajimehoshi/ebiten/v2"

	"gez/internal/digits"
)

const (
	screenW = 320
	screenH = 180
)

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

type Game struct {
	font  *digits.Font
	left  int // the two scores on show
	right int
}

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

// drawScore draws the two numbers with the sheet's digits at four times,
// centred on the middle of each half of the court.
func (g *Game) drawScore(screen *ebiten.Image) {
	screen.Fill(courtColor)
	g.font.DrawCentred(screen, g.left, 80, 40, 4)
	g.font.DrawCentred(screen, g.right, 240, 40, 4)
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawScore(screen)
}

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

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Score")
	ebiten.SetTPS(60)
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		log.Fatal(err)
	}
	g := &Game{font: font, left: 3, right: 12}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}
go vet ./...
go run ./cmd/score
A dark window with a large light 3 in the left half and a large light 12 in the right half, both near the top, drawn in blocky five-row digits.
The window after stage 1: 3 and 12 in the sheet's digits at four times, each centred on the middle of its half.

Draw splits the number from the right. For 12, the remainder gives 2, then the next division leaves 1. The code prepends each digit so they draw in reading order.

Each glyph is drawn by scaling and then moving, the same GeoM order used for images. At four times scale, one digit takes twelve pixels and the gap takes four, so the next digit starts sixteen pixels to the right.

Width counts digits and applies the same arithmetic as the Math Interlude. DrawCentred subtracts half the width from the middle and rounds to a whole pixel. 0 is one glyph wide because a score of zero still needs to show one digit.

∑ Math Interlude — twenty-eight pixels for two digits

A number with two digits at four times is two glyphs of 3 × 4 = 12 pixels with one gap of 1 × 4 = 4 between them: 12 + 4 + 12 = 28 pixels wide. Three digits are 12 + 4 + 12 + 4 + 12 = 44, and one digit is 12: the gaps are one fewer than the digits. As a formula, for n digits at scale s, the width is s × (3n + (n − 1)). Centred on column 240, the 12 has its left edge at 240 − 28 ÷ 2 = 226 and its right edge at 254.

nhow many digits the number has: 1 for 0 to 9, 2 for 10 to 99
sthe scale: how many screen pixels one sheet pixel becomes; 4 on the scoreboard
widths × (3n + (n − 1)): glyphs three wide, gaps one wide, one fewer gap than glyphs
leftthe middle column less half the width, in whole pixels

Drawing TrueType text

Go Regular is the font published by the Go project. The package golang.org/x/image/font/gofont/goregular stores the TrueType file in a byte slice named TTF. Importing it puts the font bytes in the program. Add the module before extending cmd/score.

▣ Build · stage 2 — words from a TrueType face, extend cmd/score/main.go
// cmd/score/main.go — extend
import (
	"bytes"
	"image/color"
	"log"
	"math"

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

	"gez/internal/digits"
)

type Game struct {
	font  *digits.Font
	face  *text.GoTextFace
	left  int // the two scores on show
	right int
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawScore(screen)
	g.drawWords(screen)
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Score")
	ebiten.SetTPS(60)
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		log.Fatal(err)
	}
	g := &Game{font: font, face: newFace(12), left: 3, right: 12}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}

// newFace parses the Go Regular font, which ships inside the program as a
// Go package, and returns it at the size asked for, in pixels.
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}
}

// drawText draws s in the line colour with its left edge on column x and
// its top on row y.
func drawText(dst *ebiten.Image, s string, face *text.GoTextFace, x, y float64) {
	op := &text.DrawOptions{}
	op.GeoM.Translate(x, y)
	op.ColorScale.ScaleWithColor(lineColor)
	text.Draw(dst, s, face, op)
}

// drawCentred draws s with its middle on column x: the string is measured
// and the left edge is half its width to the left, rounded down to a pixel.
func drawCentred(dst *ebiten.Image, s string, face *text.GoTextFace, x, y float64) {
	w, _ := text.Measure(s, face, 0)
	drawText(dst, s, face, math.Floor(x-w/2), y)
}

// drawWords draws the title and a line of small text under each number.
func (g *Game) drawWords(screen *ebiten.Image) {
	drawCentred(screen, "SCORE", g.face, screenW/2, 12)
	drawCentred(screen, "LEFT", g.face, 80, 70)
	drawCentred(screen, "RIGHT", g.face, 240, 70)
	drawText(screen, "P to pause", g.face, 8, 160)
}
go get golang.org/x/image@v0.43.0
go mod tidy
go mod vendor
cat go.mod
go vet ./...
go run ./cmd/score
$ go get golang.org/x/image@v0.43.0
$ 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/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 ./...
The two big numbers, with the word SCORE centred above them in a small smooth typeface, LEFT and RIGHT under each number, and P to pause in the bottom left corner.
The window after stage 2: the numbers from the sheet, and four lines of words from the TrueType face at twelve pixels.

go get prints nothing here because golang.org/x/image was already in the build list through Ebitengine's own tests. The command still writes it into go.mod as a direct requirement. go mod tidy finds the modules needed by the text package, and go mod vendor copies them into vendor.

newFace parses the font once in main. text.GoTextFace pairs the parsed font with a size in pixels. text.Draw, from Ebitengine's text package, takes draw options with a GeoM for position and a colour scale for the glyphs.

The TrueType words have soft edges because a curve crosses partial pixels. The sheet digits keep hard edges because they are tiny images drawn at an integer scale. Use the sheet for scores and the font for words.

text.Measure returns the width and height of a string in this face. drawCentred subtracts half the width and uses math.Floor so the glyphs start on a whole pixel.

Drawing an overlay

▣ Build · stage 3 — the pause overlay, extend cmd/score/main.go
// cmd/score/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/digits"
)

var (
	courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}
	lineColor  = color.RGBA{R: 232, G: 232, B: 232, A: 255}
	shadeColor = color.RGBA{R: 0, G: 0, B: 0, A: 160}
)

type Game struct {
	font   *digits.Font
	face   *text.GoTextFace
	left   int // the two scores on show
	right  int
	paused bool
}

func (g *Game) Update() error {
	if inpututil.IsKeyJustPressed(ebiten.KeyP) {
		g.paused = !g.paused
	}
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawScore(screen)
	g.drawWords(screen)
	g.drawPause(screen)
}

// drawPause darkens everything drawn so far and writes over it.
func (g *Game) drawPause(screen *ebiten.Image) {
	if !g.paused {
		return
	}
	vector.FillRect(screen, 0, 0, screenW, screenH, shadeColor, false)
	drawCentred(screen, "PAUSED", g.face, screenW/2, 84)
}
go vet ./...
go run ./cmd/score
The same window darkened to a dim grey, the numbers and words faint under a shade, with the word PAUSED bright and centred in the middle.
The window after stage 3 with P pressed once: the court, the numbers and the words under a shade, and PAUSED on top of it.

Press P and the court dims. Press P again and the picture comes back. The toggle uses IsKeyJustPressed so a held P changes the flag once.

The shade is one rectangle the size of the picture. Its alpha is 160 out of 255, so it covers about 63 percent of what is under it. The line colour at 232 comes out near 86 after the shade, dim but still visible.

A translucent colour blends with the pixel already on the screen. The word is drawn after the shade, so it stays at full brightness.

The order of the calls in Draw is the order of the layers, back to front Four boxes stacked left to right with arrows: the fill, the numbers and words, the shade, and PAUSED, labelled first to last. A note says each call paints over what is already in the pixels, so the shade dims the three calls before it and not the one after. DRAW, READ AS A LIST OF LAYERS Fill the court drawScore drawWords FillRect the shade, alpha 160 drawCentred PAUSED, on top first call at the back, last call at the front: the shade dims everything to its left and nothing to its right; swap the last two and PAUSED is dimmed with the rest
Figure 5.1 — the four calls of a paused frame in the order they run, which is the order of the layers, back to front.
⚠ Worked failure — PAUSED drawn before the shade

Swap the two lines in drawPause, writing the word first and shading after:

func (g *Game) drawPause(screen *ebiten.Image) {
	if !g.paused {
		return
	}
	drawCentred(screen, "PAUSED", g.face, screenW/2, 84)
	vector.FillRect(screen, 0, 0, screenW, screenH, shadeColor, false)
}
The paused window, but with PAUSED as dim as everything else under the shade.
The same program with the word drawn first: PAUSED is under the shade, as faint as the score.

The word is there, but it is as dim as the numbers. The shade darkens whatever the pixels hold when it is drawn, and the word was already in those pixels.

screen is one image. Each call paints over what is already there. There is no separate layer called "top"; the order of the calls creates the order you see. Put the shade back before the word.

Ordering draw calls

Draw paints into one image. The fill at the top gives each picture a fresh background. Opaque colours replace pixels; translucent colours mix with the pixels already there.

Read Draw from back to front: background first, game objects after that, then scores, then overlays. If two things overlap, the later call is the one you see on top.

Checkpoint

✓ Checkpoint — what you can now do
  • Cut a ten-glyph digit font from the sheet into a package every game can import, and draw any number with it at any scale.
  • Work out the width of a number in the digit font by hand, and centre it on a column in whole pixels.
  • Add golang.org/x/image to the module, and say why go get printed nothing and what go mod tidy added.
  • Parse Go Regular from the bytes inside the program, make a face at a size, draw a string in the line colour, and centre it by measuring it.
  • Draw a translucent shade and say, from the alpha, how bright the digits under it come out.
  • Explain from the order of two calls why a word drawn before a shade is dim, and read any Draw as a back-to-front list.
⚡ Exercises — try first, then reveal
Exercise 1 — a score that climbs. Make the left score go up by one every sixty ticks, and watch what happens to the digits when it reaches 10 and 100.

A tick counter in Update, and g.left++ when it hits a multiple of sixty. At 10 the number gets a second glyph and its left edge moves eight pixels left, because DrawCentred re-centres it; at 100 it moves eight more. The right edge moves the same amount right. A scoreboard that kept its left edge fixed would drift toward the middle instead.

Exercise 2 — the height the face reports. Print the width and height text.Measure returns for "SCORE" at 12, then at 24, and compare them with the pixels on screen.

Add a fmt.Println of the two values in main; at 12 the height is the face's line height, a little more than twelve, and the width is near 40; at 24 both double. The height is the room a line needs, not the height of the capitals, which is why the words are placed by their top and the number under them by a row chosen by eye.

Exercise 3 — a lighter shade. Change the shade's alpha to 60, then 250, and say what PAUSED looks like on each.

At 60 the court is barely dimmed, to about three quarters, and the word sits on a picture that still reads as the game; at 250 the court is nearly black and the word floats alone. 160 is a choice between the two, made so that a player can see the state they paused in.