Learn the basics of the Go-based game engine Ebitengine by implementing a Boids Flocking simulation.
- Introduction
- What is Ebitengine?
- What I built: Boids Flocking
- Implementing Boids Flocking
- Conclusion
- References
Introduction
I found a Go-based game engine called Ebitengine that looked interesting, so I decided to try making something with it.
To get started with Ebitengine, I’ll reimplement the Boids Flocking simulation I previously built with Godot Engine here.
# Version used in this post Go: 1.20.7 Ebitengine: v2.5.6
Note: This article was translated from my original post.
What is Ebitengine?
Ebitengine is:
- An open-source 2D game engine written in Go
- Cross-platform (even supports Nintendo Switch)
- Features a minimal API
- "Everything is a rectangle image" – draw one rectangular image onto another to build the game
The design philosophy is explained well by the author in these posts (but in Japanese):
- ゲームエンジンはアートである - 8 年以上自作ゲームエンジンをメンテし続けている話|Hajime Hoshi
- 既に拡張性のある無料のゲームエンジンがある中,なぜ時間と労力をかけて独自のゲームエンジンを開発していらっしゃるのでしょうか? | mond
Some notable games made with Ebitengine:
Both are developed by Odencat and even ported to Nintendo Switch. For more on why they chose Ebitengine and how they use it, this presentation is worth a read:
www.slideshare.net
What I built: Boids Flocking
Boids Flocking is an artificial life model that simulates the movement of bird flocks. The name "Boids" comes from "bird-oid", meaning "bird-like".
If you’re interested in the details of the Boids algorithm, check out this past article.
The final source code is available on GitHub. From here, we’ll walk through key points based on that code: github.com
Implementing Boids Flocking
Minimum setup: Hello, World!
Before diving into Boids Flocking, let’s first look at the minimal structure of a game in Ebitengine.
// main.go package main import ( "log" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" ) type Game struct{} func (g *Game) Update() error { return nil } func (g *Game) Draw(screen *ebiten.Image) { ebitenutil.DebugPrint(screen, "Hello, World!") } func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { return 320, 240 } func main() { ebiten.SetWindowSize(640, 480) ebiten.SetWindowTitle("Hello, World!") if err := ebiten.RunGame(&Game{}); err != nil { log.Fatal(err) } }
Create a Game
struct and implement three methods: Update
, Draw
, and Layout
. Pass it to RunGame
to launch the game.
Update
: called every frame, responsible for game logic and state updates.Draw
: also called every frame, handles rendering.Layout
: defines the game screen dimensions.
Run it with go run main.go
.
When implementing Boids Flocking, you’ll follow the same approach: logic in Update
, rendering in Draw
.
Directory structure
Writing all the code in main.go
would make it hard to read, so I split it into the following structure:
# from tree command
.
├── go.mod
├── go.sum
├── main.go
├── boids
│ ├── boid.go
│ └── game.go
└── vector
├── vec2.go
└── vec2_test.go
main.go
: entry pointboids
package: game logicvector
package: 2D vector math
Although using an existing library for vector operations is usually better, I implemented them myself as part of learning.
main.go
simply calls the game logic defined in the boids
package.
package main import ( "log" "github.com/bioerrorlog/boids-ebitengine/boids" "github.com/hajimehoshi/ebiten/v2" ) func main() { game, err := boids.NewGame() if err != nil { log.Fatal(err) } ebiten.SetWindowSize(boids.ScreenWidth, boids.ScreenHeight) ebiten.SetFullscreen(true) ebiten.SetWindowTitle("Boids") if err := ebiten.RunGame(game); err != nil { log.Fatal(err) } }
Ref. GitHub - bioerrorlog/boids-ebitengine at aa86fe9fe9d4a8626ef1d652338a5dce1e7a5f84
Implementing Game Logic
Here’s the core game logic:
package boids import ( "fmt" "image/color" "math/rand" "github.com/bioerrorlog/boids-ebitengine/vector" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" ) const ( ScreenWidth = 1920 ScreenHeight = 1080 boidCount = 100 ) type Game struct { boids []*Boid } func NewGame() (*Game, error) { g := &Game{ boids: make([]*Boid, boidCount), } for i := range g.boids { g.boids[i] = NewBoid( rand.Float64()*ScreenWidth, rand.Float64()*ScreenHeight, vector.Vec2{X: ScreenWidth / 2, Y: ScreenHeight / 2}, ) } return g, nil } func (g *Game) Update() error { for _, b := range g.boids { b.Update(g.boids) } return nil } func (g *Game) Draw(screen *ebiten.Image) { // Backgroud screen.Fill(color.RGBA{255, 245, 228, 0xff}) // Boids for _, b := range g.boids { b.Draw(screen) } // Debug fps := fmt.Sprintf("FPS: %0.2f", ebiten.ActualFPS()) ebitenutil.DebugPrint(screen, fps) } func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { return ScreenWidth, ScreenHeight }
The NewGame
function creates a Game
struct.
The Game
struct contains a slice of Boid
instances. Each frame, Update
calls the Update
method of every boid.
The Draw
method renders the background, then the boids, then a debug FPS counter.
Implementing the Boids Flocking Logic
Next is the logic for each individual Boid
, called from the Game
.
package boids import ( "image/color" "math/rand" "github.com/bioerrorlog/boids-ebitengine/vector" "github.com/hajimehoshi/ebiten/v2" ev "github.com/hajimehoshi/ebiten/v2/vector" ) const ( moveSpeed = 20 perceptionRadius = 100 steerForce = 1 alignmentForce = 0.1 cohesionForce = 0.05 separationForce = 0.3 centralizationForce = 0.3 centralizationForceRadius = 200 ) type Boid struct { position, velocity, targetCenter vector.Vec2 } func NewBoid(x, y float64, targetCenter vector.Vec2) *Boid { return &Boid{ position: vector.Vec2{X: x, Y: y}, velocity: vector.Vec2{X: rand.Float64()*2 - 1, Y: rand.Float64()*2 - 1}, targetCenter: targetCenter, } } func (b *Boid) Draw(screen *ebiten.Image) { ev.DrawFilledCircle(screen, float32(b.position.X), float32(b.position.Y), 20, color.RGBA{255, 148, 148, 0xff}, true) } func (b *Boid) Update(boids []*Boid) { neighbors := b.getNeighbors(boids) alignment := b.alignment(neighbors) cohesion := b.cohesion(neighbors) separation := b.separation(neighbors) centering := b.centralization() b.velocity = b.velocity.Add(alignment).Add(cohesion).Add(separation).Add(centering).Limit(moveSpeed) b.position = b.position.Add(b.velocity) }
NewBoid
: returns a newBoid
with random velocityDraw
: renders a filled circle for each boidUpdate
: runs the Boids algorithm
The Boids algorithm combines these three forces:
alignment
: move in the same direction as neighborscohesion
: move toward the center of nearby boidsseparation
: avoid crowding too close to others
Additionally, to prevent boids from flying off-screen, a centralization
force pulls them toward the screen center.
We won’t cover the detailed implementation of each force here. See the source code if you’re interested.
The parameters defined with const
were adjusted by trial and error to create smooth and natural movement. I’m quite pleased with how the final flocking behavior turned out.
Implementing Vector Operations
The Boid logic uses custom vector operations. (This was done manually for learning purposes; normally, using a library would be better.)
package vector import "math" type Vec2 struct { X, Y float64 } func (v Vec2) Add(other Vec2) Vec2 { return Vec2{v.X + other.X, v.Y + other.Y} } func (v Vec2) Sub(other Vec2) Vec2 { return Vec2{v.X - other.X, v.Y - other.Y} } func (v Vec2) Mul(scalar float64) Vec2 { return Vec2{v.X * scalar, v.Y * scalar} } func (v Vec2) Div(scalar float64) Vec2 { return Vec2{v.X / scalar, v.Y / scalar} } func (v Vec2) Length() float64 { return math.Sqrt(v.X*v.X + v.Y*v.Y) } func (v Vec2) Normalize() Vec2 { length := v.Length() if length != 0 { return Vec2{v.X / length, v.Y / length} } return Vec2{0, 0} } func (v Vec2) Limit(max float64) Vec2 { if v.Length() > max { return v.Normalize().Mul(max) } return v } func (v Vec2) DistanceTo(other Vec2) float64 { diff := v.Sub(other) return diff.Length() }
These vector math functions are used in the Boids algorithm.
Although it's hard to write tests for game logic or rendering, you can easily write tests for pure Go code like this:
package vector import ( "math" "testing" ) func TestVec2_Add(t *testing.T) { tests := []struct { name string v Vec2 other Vec2 want Vec2 }{ {"basic add", Vec2{3, 4}, Vec2{1, 2}, Vec2{4, 6}}, {"add with zero", Vec2{3, 4}, Vec2{0, 0}, Vec2{3, 4}}, {"add with negative values", Vec2{3, 4}, Vec2{-1, -2}, Vec2{2, 2}}, {"add with same values", Vec2{3, 4}, Vec2{3, 4}, Vec2{6, 8}}, {"add with floating values", Vec2{3.5, 4.5}, Vec2{1.5, 2.5}, Vec2{5, 7}}, {"add with mix of positive and negative", Vec2{3, -4}, Vec2{-1, 2}, Vec2{2, -2}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.v.Add(tt.other); got != tt.want { t.Errorf("Vec2.Add() = %v, want %v", got, tt.want) } }) } } func TestVec2_Sub(t *testing.T) { tests := []struct { name string v Vec2 other Vec2 want Vec2 }{ {"basic subtraction", Vec2{3, 4}, Vec2{1, 2}, Vec2{2, 2}}, {"subtract with zero", Vec2{3, 4}, Vec2{0, 0}, Vec2{3, 4}}, {"subtract negative values", Vec2{3, 4}, Vec2{-1, -2}, Vec2{4, 6}}, {"subtract same values", Vec2{3, 4}, Vec2{3, 4}, Vec2{0, 0}}, {"subtract floating values", Vec2{3.5, 4.5}, Vec2{1.5, 2.5}, Vec2{2, 2}}, {"subtract mix of positive and negative", Vec2{3, -4}, Vec2{-1, 2}, Vec2{4, -6}}, {"subtract resulting in negative", Vec2{3, 4}, Vec2{5, 6}, Vec2{-2, -2}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.v.Sub(tt.other); got != tt.want { t.Errorf("Vec2.Sub() = %v, want %v", got, tt.want) } }) } }
If all tests pass, you're good to go.
$ go test -v ./... ? github.com/bioerrorlog/boids-ebitengine [no test files] ? github.com/bioerrorlog/boids-ebitengine/boids [no test files] === RUN TestVec2_Add === RUN TestVec2_Add/basic_add === RUN TestVec2_Add/add_with_zero === RUN TestVec2_Add/add_with_negative_values === RUN TestVec2_Add/add_with_same_values === RUN TestVec2_Add/add_with_floating_values === RUN TestVec2_Add/add_with_mix_of_positive_and_negative --- PASS: TestVec2_Add (0.00s) --- PASS: TestVec2_Add/basic_add (0.00s) --- PASS: TestVec2_Add/add_with_zero (0.00s) --- PASS: TestVec2_Add/add_with_negative_values (0.00s) --- PASS: TestVec2_Add/add_with_same_values (0.00s) --- PASS: TestVec2_Add/add_with_floating_values (0.00s) --- PASS: TestVec2_Add/add_with_mix_of_positive_and_negative (0.00s) ... PASS ok github.com/bioerrorlog/boids-ebitengine/vector (cached)
That completes the game-side implementation.
Setting Up CI
Finally, I set up GitHub Actions to automate static analysis, builds, and tests.
name: Test on: - push - pull_request jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Setup Go uses: actions/setup-go@v3 with: go-version: '1.20' - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y libasound2-dev libgl1-mesa-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libxxf86vm-dev - name: Run golangci-lint uses: golangci/golangci-lint-action@v3 with: version: latest - name: Vet run: go vet ./... - name: Build run: go build -v ./... - name: Test run: go test -v ./...
After installing the necessary libraries, it runs static analysis (golangci-lint
and go vet
), builds the project, and runs tests.
Note: if you skip the Install dependencies
step, go vet
will fail on Ubuntu. These packages were referenced from Ebitengine’s official workflow.
Conclusion
I implemented Boids Flocking using Ebitengine, a game engine written in Go.
Personally, I’ve always felt:
- If I’m making a game myself, I’ll stick with simple 2D games (I love 2D and easily get motion sickness from 3D)
- In that case, most game engines have way too many features
- Debugging can be painful when something goes wrong (which kills motivation)
- (Though if you master them, you probably can work very efficiently...)
So, I really appreciated the simplicity of Ebitengine's API. It felt clean and enjoyable to work with.
I hope this example helps someone else looking to get started.
[Related Articles]
References
- GitHub - hajimehoshi/ebiten: Ebitengine - A dead simple 2D game engine for Go
- Ebitengine - A dead simple 2D game engine for Go
- ebiten package - github.com/hajimehoshi/ebiten/v2 - Go Packages
- ゲームエンジンはアートである - 8 年以上自作ゲームエンジンをメンテし続けている話|Hajime Hoshi
- 既に拡張性のある無料のゲームエンジンがある中,なぜ時間と労力をかけて独自のゲームエンジンを開発していらっしゃるのでしょうか? | mond
- Real-world game development with Ebitengine - How to make the best-selling Go game | PPT
- GitHub - bioerrorlog/boids-ebitengine: Boids Flocking in Ebitengine