Making games in Go with Ebitengine
Like a lot of people, you may be using Go at work to develop backend applications or create infrastructure tools. But you may not be aware that you can actually make video games in Go using Ebitengine.
Just enough to make games
Ebitengine aims at being dead simple. It was explicitly designed to have the most minimalistic API possible and still let people make the games they want. Of course, it has some limitations, for example it doesn’t handle 3D (at least, not officially). But it is still functional enough that many commercial indie games have been made with it.
Switching to Ebitengine from Unity or Godot is a bit like switching from Spring Framework to a micro-framework like Gin or Chi. At first, you’ll feel like you’re spending all your time re-inventing the wheel, but when you start getting used to it, you realize that the increased flexibility and reliability makes your life easier in the long run.
Graphics
The core of Ebitengine is its ability to display and manipulate images.
The screen itself is considered an image, on which we are going to draw the rest of our game.
Each time we display an image using DrawImage, we can give it options.
Those allow us to change its position, scale, rotation, color or filtering.
Source code
package main
import (
"bytes"
_ "embed"
"image/png"
"log"
"math/rand"
"github.com/hajimehoshi/ebiten/v2"
)
var game *Game
//go:embed logo.png
var logoFile []byte
const (
maxSpeed = 3
minSpeed = 1
scale = 2
)
func randomColor() ebiten.ColorScale {
scale := ebiten.ColorScale{}
scale.Scale(
rand.Float32(),
rand.Float32(),
rand.Float32(),
1,
)
return scale
}
func randomSpeed() float64 {
return minSpeed + rand.Float64()*(maxSpeed-minSpeed)
}
// Game implements ebitengine Game interface and represents our game loop
type Game struct {
image *ebiten.Image
geometry ebiten.GeoM
direction [2]float64
color ebiten.ColorScale
width int
height int
}
// Update is called every frame to update the current game state
func (g *Game) Update() error {
g.geometry.Translate(
g.direction[0],
g.direction[1],
)
// bounce and change color if out of screen
x := g.geometry.Element(0, 2)
x2 := x + float64(g.image.Bounds().Dx())*scale
if x < 0 {
g.direction[0] = randomSpeed()
g.color = randomColor()
} else if x2 > float64(g.width) {
g.direction[0] = -1 * randomSpeed()
g.color = randomColor()
}
y := g.geometry.Element(1, 2)
y2 := y + float64(g.image.Bounds().Dy())*scale
if y < 0 {
g.direction[1] = randomSpeed()
g.color = randomColor()
} else if y2 > float64(g.height) {
g.direction[1] = -1 * randomSpeed()
g.color = randomColor()
}
return nil
}
// Draw is called every frame to display images on the screen
func (g *Game) Draw(screen *ebiten.Image) {
screen.DrawImage(g.image, &ebiten.DrawImageOptions{
GeoM: game.geometry,
ColorScale: g.color,
})
}
// Layout is called every frame to indicate the window/screen size
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
g.width = outsideWidth
g.height = outsideHeight
return outsideWidth, outsideHeight
}
func init() {
reader := bytes.NewReader(logoFile)
png, err := png.Decode(reader)
if err != nil {
log.Panicf("Failed to decode image:\n%v", err)
}
image := ebiten.NewImageFromImage(png)
geom := ebiten.GeoM{}
geom.Scale(2, 2)
direction := [2]float64{
randomSpeed(),
randomSpeed(),
}
color := randomColor()
game = &Game{
image: image,
geometry: geom,
direction: direction,
color: color,
}
}
func main() {
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
Input
Like any proper engine, Ebitengine helps you handle player input.
Basic functions like IsKeyPressed or IsMouseButtonPressed are directly found inside the main ebiten module.
More advanced functions are placed in the inpututil module.
Those can be very useful if you need to work with controllers or touchscreens.
Source code (main.go)
package main
import (
"bytes"
_ "embed"
"image/color"
"image/png"
"log"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
var game *Game
const (
screenWidth = 160
screenHeight = 240
)
// Game is our top level structure
type Game struct {
egg *Egg
nests []*Nest
scroll float64
touchIDs []ebiten.TouchID
}
// Update handles the user input, movement and scrolling
func (g *Game) Update() error {
if g.scroll > 0 {
g.HandleScrolling()
return nil // the rest of the game logic is blocked during scrolling
}
g.touchIDs = inpututil.AppendJustPressedTouchIDs(g.touchIDs[:0])
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) ||
inpututil.IsKeyJustPressed(ebiten.KeySpace) ||
len(g.touchIDs) != 0 {
g.egg.Jump()
}
g.egg.UpdatePosition()
for _, nest := range g.nests {
nest.UpdatePosition()
if g.egg.IsFalling() {
if nest.CheckLanding(g.egg) {
g.nests = append(g.nests, NewMovingNest())
g.scroll = screenHeight / 3
}
}
}
return nil
}
// HandleScrolling smoothly moves the elements by a third of the screen and removes old nests
func (g *Game) HandleScrolling() {
distance := float64(screenHeight) / 30
g.scroll -= distance
g.egg.Scroll(distance)
visibleNests := []*Nest{}
for _, nest := range g.nests {
nest.Scroll(distance)
if !nest.IsOutOfScreen() {
visibleNests = append(visibleNests, nest)
}
}
g.nests = visibleNests
}
// Draw displays the background, egg and nests
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{
R: 77,
G: 186,
B: 233,
A: 255,
})
g.egg.Draw(screen)
for _, nest := range g.nests {
nest.Draw(screen)
}
}
// Layout returns a constant screen width and height
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenWidth, screenHeight
}
func loadImage(file []byte) *ebiten.Image {
reader := bytes.NewReader(file)
png, err := png.Decode(reader)
if err != nil {
log.Panicf("Failed to decode image:\n%v", err)
}
return ebiten.NewImageFromImage(png)
}
func init() {
eggImage = loadImage(eggFile)
nestImage = loadImage(nestFile)
game = &Game{
egg: NewEgg(),
nests: []*Nest{NewBottomNest(), NewTopNest()},
touchIDs: []ebiten.TouchID{},
scroll: 0,
}
}
func main() {
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
Source code (egg.go)
package main
import (
_ "embed"
"github.com/hajimehoshi/ebiten/v2"
)
const (
eggWidth = 16
eggHeight = 16
)
//go:embed egg.png
var eggFile []byte
var eggImage *ebiten.Image
// Egg represents our player
type Egg struct {
geom ebiten.GeoM
velocity float64
gravity float64
}
// NewEgg creates an egg at the beginning of the game
func NewEgg() *Egg {
eggGeom := ebiten.GeoM{}
eggGeom.Translate(
screenWidth/2-eggWidth/2,
screenHeight*2/3,
)
return &Egg{
geom: eggGeom,
velocity: 0,
gravity: 0,
}
}
// Draw draws the egg sprite on the screen
func (e *Egg) Draw(screen *ebiten.Image) {
screen.DrawImage(eggImage, &ebiten.DrawImageOptions{
GeoM: e.geom,
})
}
// Jump triggers the start of a jump
func (e *Egg) Jump() {
if e.velocity < 0 {
return // we're already jumping
}
e.velocity = -10
e.gravity = 0.5
}
// UpdatePosition moves the egg during jumps / falls
func (e *Egg) UpdatePosition() {
e.velocity += e.gravity
e.geom.Translate(
0,
e.velocity,
)
}
// GetPosition returns the egg X/Y
func (e *Egg) GetPosition() (float64, float64) {
eggX := e.geom.Element(0, 2)
eggY := e.geom.Element(1, 2)
return eggX, eggY
}
// Stop cancels any jump / fall
func (e *Egg) Stop() {
e.gravity = 0
e.velocity = 0
}
// IsFalling returns true if the egg is currently falling
func (e *Egg) IsFalling() bool {
return e.velocity > 0
}
// Scroll moves the egg during scrollig
func (e *Egg) Scroll(distance float64) {
e.geom.Translate(0, distance)
}
Source code (nest.go)
package main
import (
_ "embed"
"math/rand"
"github.com/hajimehoshi/ebiten/v2"
)
const (
nestWidth = 32
nestHeight = 16
)
//go:embed nest.png
var nestFile []byte
var nestImage *ebiten.Image
// Nest represents the platforms
type Nest struct {
geom ebiten.GeoM
landed bool
velocity float64
}
// NewBottomNest creates the starting point
func NewBottomNest() *Nest {
nestGeom := ebiten.GeoM{}
nestGeom.Translate(
screenWidth/2-nestWidth/2,
screenHeight*2/3+eggHeight/2,
)
return &Nest{
geom: nestGeom,
landed: true,
velocity: 0,
}
}
// NewTopNest creates the second (fixed) nest
func NewTopNest() *Nest {
nestGeom := ebiten.GeoM{}
nestGeom.Translate(
screenWidth/2-nestWidth/2,
screenHeight/3+eggHeight/2,
)
return &Nest{
geom: nestGeom,
landed: false,
velocity: 0,
}
}
// NewMovingNest creates a moving platform
func NewMovingNest() *Nest {
nestGeom := ebiten.GeoM{}
nestGeom.Translate(
screenWidth/2-nestWidth/2,
0,
)
velocity := 0.5 + rand.Float64()
if rand.Intn(2) == 1 {
velocity *= -1
}
return &Nest{
geom: nestGeom,
landed: false,
velocity: velocity,
}
}
// Draw displays the nest on the screen
func (n *Nest) Draw(screen *ebiten.Image) {
screen.DrawImage(nestImage, &ebiten.DrawImageOptions{
GeoM: n.geom,
})
}
// UpdatePosition moves the nest
func (n *Nest) UpdatePosition() {
n.geom.Translate(n.velocity, 0)
x := n.geom.Element(0, 2)
if x < 0 || x+nestWidth > screenWidth {
n.velocity *= -1
}
}
// CheckLanding returns true if the egg has landed in a new nest
func (n *Nest) CheckLanding(egg *Egg) bool {
eggX, eggY := egg.GetPosition()
nestX := n.geom.Element(0, 2)
nestY := n.geom.Element(1, 2)
if eggX >= nestX &&
eggX+eggWidth <= nestX+nestWidth &&
eggY+eggHeight >= nestY+5 &&
eggY+eggHeight <= nestY+15 {
// landing in a nest
egg.Stop()
if !n.landed {
// we landed in a new nest
n.velocity = 0
n.landed = true
return true
}
return false
}
return false
}
// Scroll moves the nest during scrolling
func (n *Nest) Scroll(distance float64) {
n.geom.Translate(0, distance)
}
// IsOutOfScreen returns true if the nest is not displayed anymore
func (n *Nest) IsOutOfScreen() bool {
y := n.geom.Element(1, 2)
return y > screenHeight
}
Audio
Ebitengine’s audio module contains everything you need to play sound effects or music.
On top of providing the usual Play/Pause/Rewind functions, it handles decoding of mp3, ogg, and wav audio files.
Low-level management of audio stream is available through the oto library, which is also part of the Ebitengine project.
Source code (sample.go)
package main
import (
"bytes"
"embed"
"image/png"
"io/fs"
"log"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
)
const (
sampleWidth = 36
sampleHeight = 36
)
//go:embed images
var images embed.FS
//go:embed sounds
var sounds embed.FS
// Sample represents a sound sample and its icon
type Sample struct {
image *ebiten.Image
geom ebiten.GeoM
player *audio.Player
}
// IsTargeted returns true if the mouse or touch position is on the sample
func (s *Sample) IsTargeted(x, y int) bool {
sampleX := int(s.geom.Element(0, 2))
sampleY := int(s.geom.Element(1, 2))
return x >= sampleX &&
x < sampleX+sampleWidth &&
y >= sampleY &&
y < sampleY+sampleWidth
}
// Play rewinds and plays the sample
func (s *Sample) Play() {
err := s.player.Rewind()
if err != nil {
log.Panicf("Failed to rewind player: %v", err)
}
s.player.Play()
}
// Draw displays the sample icon
func (s *Sample) Draw(screen *ebiten.Image) {
screen.DrawImage(s.image, &ebiten.DrawImageOptions{
GeoM: s.geom,
})
}
func createPlayer(context *audio.Context, filename string) *audio.Player {
file, err := fs.ReadFile(
sounds,
filepath.Join("sounds", filename),
)
if err != nil {
log.Panicf("Failed to read embedded sounds fs: %v", err)
}
reader := bytes.NewReader(file)
stream, err := wav.DecodeWithSampleRate(sampleRate, reader)
if err != nil {
log.Panicf("Failed to decode sample: %v", err)
}
player, err := context.NewPlayer(stream)
if err != nil {
log.Panicf("Failed to create player: %v", err)
}
return player
}
func loadImage(filename string) *ebiten.Image {
file, err := fs.ReadFile(
images,
filepath.Join("images", filename),
)
if err != nil {
log.Panicf("Failed to read embedded images fs: %v", err)
}
reader := bytes.NewReader(file)
png, err := png.Decode(reader)
if err != nil {
log.Panicf("Failed to decode image:\n%v", err)
}
return ebiten.NewImageFromImage(png)
}
// Skull creates our skull sample (top left)
func Skull(context *audio.Context) *Sample {
return &Sample{
image: loadImage("skull.png"),
player: createPlayer(context, "skull.wav"),
geom: ebiten.GeoM{},
}
}
// Alert creates our alert sample (top right)
func Alert(context *audio.Context) *Sample {
geom := ebiten.GeoM{}
geom.Translate(36, 0)
return &Sample{
image: loadImage("alert.png"),
player: createPlayer(context, "alert.wav"),
geom: geom,
}
}
// Question creates our question sample (bottom left)
func Question(context *audio.Context) *Sample {
geom := ebiten.GeoM{}
geom.Translate(0, 36)
return &Sample{
image: loadImage("question.png"),
player: createPlayer(context, "question.wav"),
geom: geom,
}
}
// Heart creates our heart sample (bottom right)
func Heart(context *audio.Context) *Sample {
geom := ebiten.GeoM{}
geom.Translate(36, 36)
return &Sample{
image: loadImage("heart.png"),
player: createPlayer(context, "heart.wav"),
geom: geom,
}
}
Source code (main.go)
package main
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
const (
screenWidth = 72
screenHeight = 72
sampleRate = 44100
)
var game *Game
// Game contains a collection of samples
type Game struct {
samples []*Sample
}
// Update plays a sample when it is clicked or touched
func (g *Game) Update() error {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
for _, sample := range g.samples {
if sample.IsTargeted(ebiten.CursorPosition()) {
sample.Play()
}
}
}
touchIDs := inpututil.AppendJustPressedTouchIDs(nil)
for _, touchID := range touchIDs {
for _, sample := range g.samples {
if sample.IsTargeted(ebiten.TouchPosition(touchID)) {
sample.Play()
}
}
}
return nil
}
// Draw displays every sample's icon
func (g *Game) Draw(screen *ebiten.Image) {
for _, sample := range g.samples {
sample.Draw(screen)
}
}
// Layout returns a fixed 72 * 72 layout
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenWidth, screenHeight
}
func init() {
context := audio.NewContext(sampleRate)
game = &Game{
samples: []*Sample{
Skull(context),
Alert(context),
Question(context),
Heart(context),
},
}
}
func main() {
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
Shaders
Go code usually runs on the CPU. Sometimes if your game needs faster processing for animations or effects, you may want to use shaders. Shaders are small pieces of software that will be executed on the GPU. This allows you to execute a function over every pixel of an image in parallel.
Ebitengine comes with its own shader language called Kage. It is very similar to Go. So much, that syntax highlighting tools that work with Go should not have any problems working with Kage. If you want to learn more about this, I recommend checking out tinne26’s Kage’s desk
Source code (main.go)
package main
import (
_ "embed"
"log"
"time"
"github.com/hajimehoshi/ebiten/v2"
)
//go:embed shader.kage
var shaderFile []byte
const (
screenWidth = 512
screenHeight = 512
)
var game *Game
// Game contains the compiled shader and keeps track of the time
type Game struct {
shader *ebiten.Shader
startTime time.Time
}
// Draw displays the shader on the entire screen
func (g *Game) Draw(screen *ebiten.Image) {
screen.DrawRectShader(
screenWidth,
screenHeight,
g.shader,
&ebiten.DrawRectShaderOptions{
Uniforms: map[string]any{
"Center": []float32{
float32(screenWidth) / 2,
float32(screenHeight) / 2,
},
"Time": time.Now().Sub(g.startTime).Seconds(),
},
},
)
}
// Update does nothing here
func (g *Game) Update() error {
return nil
}
// Layout returns a fixed width/height
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenWidth, screenHeight
}
func init() {
shader, err := ebiten.NewShader(shaderFile)
if err != nil {
log.Panicf("Failed to create shader: %f", err)
}
game = &Game{
shader: shader,
startTime: time.Now(),
}
}
func main() {
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
Source code (shader.kage)
//kage:unit pixels
package main
// Center is the coordinates of the center of the screen
var Center vec2
// Time allows us to update the shader over time
var Time float
// Fragment is the main shader function, run over every pixel
func Fragment(targetCoords vec4, sourceCoords vec2, color vec4) vec4 {
//delta is the vector from the center to our pixel
delta := targetCoords.xy - Center
distance := length(delta)
angle := atan2(delta.y, delta.x)
//band is going to generate alternate bands based on distance and angle
spiralValue := distance + Time * 50.0 - 10.0 * angle
band := mod(spiralValue, 63.0)
if band < 32.0 {
return vec4(1, 0, 0, 1) // red
} else {
return vec4(0, 0, 0, 1) // black
}
}
More than just PC games
Go natively works well on Windows, Mac, and Linux. As you can see above, it can also run in your browser with WebAssembly. Thanks to some pretty clever tricks, you can actually compile go for basically any system that implements a libc. For example, you can compile Ebitengine games for the Nintendo Switch (as long as you have access to the required SDK and hardware).
Since Ebitengine is pretty flexible, it can be used for other things than games. Guigui is a GUI Framework that makes use of it. It is currently in alpha, and is being very actively developed by the same people who made Ebitengine.