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

The Game Loop

The game loop

This chapter builds a window with a white rectangle in it. The rectangle moves from left to right, two pixels at a time, and comes back in from the left when it goes off the right edge.

A game loop is a loop that runs until the game closes. Each pass updates the game's state and then draws a picture of it. Ebitengine, the library this book uses, runs the loop. It calls your Update method sixty times a second and your Draw method each time the display wants a new picture. A third method, Layout, tells it how big the picture is.

Installing Go

⚙ Tool — Go 1.26 and a Bash terminal

Download Go from go.dev/dl and follow the install steps at go.dev/doc/install. On Linux, unpack the archive into /usr/local, add /usr/local/go/bin to your PATH, and open a new terminal.

Every command in this book is a Bash command. On Windows, install Git for Windows and use its Git Bash terminal.

▣ Build · stage 1 — check the install
go version
mkdir -p ~/scratch
cd ~/scratch
// hello.go — create
package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("go runs on", runtime.GOOS+"/"+runtime.GOARCH)
}
$ go version
go version go1.26.7 linux/amd64
$ go run hello.go
go runs on linux/amd64

Your go version line ends with your own patch number, operating system and processor: a Mac prints darwin/arm64, Windows prints windows/amd64. If the shell cannot find go, open a new terminal.

package main marks a file that belongs to a program. func main is where the program starts. The import block names the packages the file uses. go run compiles the file and runs it. If this program runs, Go is installed.

▣ Build · stage 2 — the gez module
cd ~
mkdir gez
cd gez
go mod init gez
go mod edit -go=1.26
cat go.mod
$ go mod init gez
go: creating new go.mod: module gez
$ cat go.mod
module gez

go 1.26

A Go project is a module: a directory with a go.mod file at its root. The file names the module and the Go version it is written for. go mod init writes your exact toolchain version, such as 1.26.7; go mod edit -go=1.26 changes it to the release. Every go command from here on is typed inside ~/gez.

◆ Note — the editor

Any text editor works. VS Code with its Go extension marks errors as you type and formats on save.

What Ebitengine needs on your system

On Linux and macOS, Ebitengine is compiled against C libraries for the window system and the sound card. Install them before the next stage, or the first compile fails with an error about a missing header.

⚙ Tool — system packages for Ebitengine
  • Linux (Debian, Ubuntu): a C compiler and the OpenGL, X11 and ALSA headers.
    sudo apt install gcc pkg-config libgl1-mesa-dev xorg-dev libasound2-dev
    Other distributions' package names are at ebitengine.org/en/documents/install.html.
  • macOS: the Xcode command-line tools, installed with xcode-select --install.
  • Windows: nothing beyond Go.

The Game interface

Ebitengine runs any value that has three methods: Update, Draw and Layout. In Go, a set of method names and signatures is an interface; this one is ebiten.Game.

▣ Build · stage 3 — a window with one rectangle
// cmd/window/main.go — create
package main

import (
	"image/color"
	"log"

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

// The picture the game draws is 320 pixels wide and 180 tall. The window
// is a separate matter.
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}
)

// Game is what Ebitengine runs: one value with the three methods below.
type Game struct{}

// Update runs sixty times a second. Nothing changes yet.
func (g *Game) Update() error {
	return nil
}

// Draw runs whenever the display wants a picture.
func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(courtColor)
	vector.FillRect(screen, 158, 78, 4, 24, lineColor, false)
}

// Layout is told the window's size and answers with the picture's.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("A window")
	ebiten.SetTPS(60)
	if err := ebiten.RunGame(&Game{}); err != nil {
		log.Fatal(err)
	}
}
go get github.com/hajimehoshi/ebiten/v2@v2.9.11
go mod tidy
go mod vendor
ls vendor
cat go.mod
go vet ./...
go run ./cmd/window
$ go get github.com/hajimehoshi/ebiten/v2@v2.9.11
go: added github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1
go: added github.com/ebitengine/hideconsole v1.0.0
go: added github.com/ebitengine/purego v0.9.0
go: added github.com/hajimehoshi/ebiten/v2 v2.9.11
go: added github.com/jezek/xgb v1.1.1
go: added golang.org/x/sync v0.21.0
go: added golang.org/x/sys v0.44.0
$ go mod tidy
$ go mod vendor
$ ls vendor
github.com
golang.org
modules.txt
$ cat go.mod
module gez

go 1.26

require github.com/hajimehoshi/ebiten/v2 v2.9.11

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/jezek/xgb v1.1.1 // indirect
	golang.org/x/sync v0.21.0 // indirect
	golang.org/x/sys v0.44.0 // indirect
)
$ go vet ./...
A dark window with one small white rectangle standing upright in its middle.
The game after stage 3: the court colour, with a white rectangle in the middle.

go get adds the named release of Ebitengine to go.mod, with the six modules it depends on marked indirect. Your run may print seven go: downloading lines first. go mod tidy gives Ebitengine its own require line. go mod vendor copies all seven modules into a vendor directory, and every build from now on uses that copy. go vet ./... checks every package in the module and prints nothing when it passes. Run it before every go run in this book.

go run ./cmd/window opens a window titled A window, 960 by 540, with the rectangle in the middle. The terminal prints nothing. Close the window to stop the program. Every chapter in this volume adds its own directory under cmd/.

Draw receives screen, an image 320 pixels wide and 180 tall, and paints it with two Ebitengine calls. screen.Fill paints the whole image one colour. vector.FillRect paints a rectangle with its top-left corner at column 158, row 78, 4 wide and 24 tall; the last argument, false, keeps the edges hard. RunGame takes a pointer to a new Game, opens the window and runs the loop until the window closes. The reference for every Ebitengine call is pkg.go.dev/github.com/hajimehoshi/ebiten/v2.

Ticks and draws

A tick is one call to Update. SetTPS(60) asks for sixty ticks a second, so a tick is one sixtieth of a second of game time. A draw is one call to Draw, made at the display's rate, which can be higher or lower than the tick rate.

▣ Build · stage 4 — moving the rectangle in Update
// cmd/window/main.go — replace
package main

import (
	"image/color"
	"log"

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

// The picture the game draws is 320 pixels wide and 180 tall. The window
// is a separate matter.
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}
)

// Game is what Ebitengine runs: one value with the three methods below.
type Game struct {
	x float64 // the rectangle's left edge, in the picture's pixels
}

// Update runs sixty times a second. Everything that changes, changes here.
func (g *Game) Update() error {
	g.x += 2
	if g.x >= screenW {
		g.x = -4 // off the right edge: come back in from the left
	}
	return nil
}

// Draw runs whenever the display wants a picture, and paints what Update decided.
func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(courtColor)
	vector.FillRect(screen, float32(g.x), 78, 4, 24, lineColor, false)
}

// Layout is told the window's size and answers with the picture's.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("A window")
	ebiten.SetTPS(60)
	if err := ebiten.RunGame(&Game{x: 158}); err != nil {
		log.Fatal(err)
	}
}
go vet ./...
go run ./cmd/window
The same dark window, with the white rectangle now to the right of centre.
The game one second after it starts: sixty ticks have moved the rectangle from column 158 to column 278.

Four things changed: the field x, the body of Update, the first argument to FillRect, and the starting value in main. Update adds two to x on every tick. At 320 the rectangle is off the right edge, so Update sets x to -4 and the rectangle slides in from the left. Draw paints the rectangle where Update put it.

Everything that moves, moves in Update. Draw only paints what Update decided. That rule holds for every game in this book.

The rectangle moves 120 pixels a second, sixty ticks of two pixels, on every machine. x is a float64 because later games move by half a pixel a tick. FillRect takes a float32, so the position is converted there.

⚠ Worked failure — the rectangle moved in Draw

The lines that move the rectangle compile and run in Draw too. Take them out of Update and put them at the top of Draw:

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

func (g *Game) Draw(screen *ebiten.Image) {
	g.x += 2
	if g.x >= screenW {
		g.x = -4
	}
	screen.Fill(courtColor)
	vector.FillRect(screen, float32(g.x), 78, 4, 24, lineColor, false)
}

On a 60 Hz display with nothing else running, the rectangle moves at about the same speed as before. The two pictures below are the same program after sixty ticks on two other displays: one that draws three times a tick, as a 180 Hz monitor does, and one that draws every other tick, as a busy laptop does.

The dark window with the white rectangle a little right of centre.
Sixty ticks with the movement in Draw, three draws a tick: 180 steps of two pixels and one wrap put the rectangle at column 194.
The dark window with the white rectangle further right than in the picture above, but short of column 278.
The same sixty ticks, one draw every two ticks: thirty steps put the rectangle at column 218.

The rectangle now moves two pixels every draw, and the display decides how many draws there are. After sixty ticks it is at column 194 on one display and 218 on the other; with the movement in Update it is at 278 on every machine. The game has no speed of its own. Move the lines back to Update.

The picture and the window

The picture is 320 by 180. The window is 960 by 540, three times larger. Ebitengine calls Layout with the window's size, makes the image it hands to Draw at the size Layout returns, and scales that image up to fill the window. So the rectangle, 4 pixels wide in the picture, shows 12 pixels wide on screen. Drag the window's corner to any size and the picture scales with it; the game never learns the window changed. A game that counted in window pixels would have to rescale everything each time.

Draw paints 320 by 180; Layout returns that size; Ebitengine scales it into whatever window there is Left: the 320 by 180 picture Draw paints, with the rectangle in it. Right: the 960 by 540 window, three times larger, showing the same picture scaled up; Layout(960, 540) returns (320, 180). WHAT DRAW PAINTS 320 × 180 rectangle at (158, 78), 4 × 24 x grows by 2 every tick WHAT THE WINDOW SHOWS Layout(960, 540) → (320, 180) window 960 × 540, the picture scaled ×3 a tick is 1/60 s in either; the display's rate is its own
Figure 1.1 — the picture and the window. Draw paints 320 by 180. Layout returns that size whatever the window is. Ebitengine scales the picture to the window.

Checkpoint

✓ Checkpoint — what you can now do
  • Install Go and check it with go version and a small program.
  • Create the gez module, add Ebitengine at v2.9.11 and vendor it.
  • Write a type with Update, Draw and Layout, hand it to ebiten.RunGame, and say how often each method is called.
  • Move a rectangle two pixels a tick in Update and wrap it at the edge.
  • Explain why the same lines in Draw put the rectangle in a different place on every display.
⚡ Exercises — try first, then reveal
Exercise 1 — a second rectangle. Add a second rectangle that starts at column 20 and moves one pixel a tick. Which one reaches the right edge first?

Add a second field, a second += in Update and a second FillRect in Draw. The first rectangle reaches 320 on tick 81, the second on tick 300, five seconds after the window opens.

Exercise 2 — a taller picture. Change Layout to return 320 by 360 and run the program. What happens to the window, and what happens to the rectangle?

The window stays 960 by 540. Ebitengine fits the taller picture inside it, scaled by 1.5 and centred, with dark bands at the sides. The rectangle sits in the top quarter, six pixels wide on screen instead of twelve. Update and Draw did not change.

Exercise 3 — thirty ticks a second. Change SetTPS(60) to SetTPS(30). Predict the rectangle's speed, then run it.

Half the speed: sixty pixels a second, so the rectangle takes 2.7 seconds from the middle to the edge instead of 1.35. Draw still runs at the display's rate, so the rectangle moves in visible two-pixel steps thirty times a second.