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
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.
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.
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.
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.
-
Linux (Debian, Ubuntu): a C compiler and the OpenGL, X11 and
ALSA headers.
Other distributions' package names are atsudo apt install gcc pkg-config libgl1-mesa-dev xorg-dev libasound2-devebitengine.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.
// 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 ./...
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.
// 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
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.
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 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 whatever the window is.
Ebitengine scales the picture to the window.
Checkpoint
- Install Go and check it with
go versionand a small program. - Create the
gezmodule, add Ebitengine atv2.9.11and vendor it. - Write a type with
Update,DrawandLayout, hand it toebiten.RunGame, and say how often each method is called. - Move a rectangle two pixels a tick in
Updateand wrap it at the edge. - Explain why the same lines in
Drawput the rectangle in a different place on every display.
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.