Ebitengine Tutorial: Boids Flocking in Golang

Learn the basics of the Go-based game engine Ebitengine by implementing a Boids Flocking simulation.

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

ebitengine.org

The design philosophy is explained well by the author in these posts (but in Japanese):

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

https://github.com/bioerrorlog/boids-ebitengine/blob/main/screenshots/demo.gif?raw=true

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.

Displaying Hello, World! with Ebitengine

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 point
  • boids package: game logic
  • vector 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
}

Ref. boids-ebitengine/boids/game.go at aa86fe9fe9d4a8626ef1d652338a5dce1e7a5f84 · bioerrorlog/boids-ebitengine · GitHub

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)
}

Ref. boids-ebitengine/boids/boid.go at aa86fe9fe9d4a8626ef1d652338a5dce1e7a5f84 · bioerrorlog/boids-ebitengine · GitHub

  • NewBoid: returns a new Boid with random velocity
  • Draw: renders a filled circle for each boid
  • Update: runs the Boids algorithm

The Boids algorithm combines these three forces:

  • alignment: move in the same direction as neighbors
  • cohesion: move toward the center of nearby boids
  • separation: 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.

https://github.com/bioerrorlog/boids-ebitengine/blob/main/screenshots/demo.gif?raw=true

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()
}

Ref. boids-ebitengine/vector/vec2.go at aa86fe9fe9d4a8626ef1d652338a5dce1e7a5f84 · bioerrorlog/boids-ebitengine · GitHub

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)
            }
        })
    }
}

Ref. boids-ebitengine/vector/vec2_test.go at aa86fe9fe9d4a8626ef1d652338a5dce1e7a5f84 · bioerrorlog/boids-ebitengine · GitHub

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 ./...

Ref. boids-ebitengine/.github/workflows/test.yml at aa86fe9fe9d4a8626ef1d652338a5dce1e7a5f84 · bioerrorlog/boids-ebitengine · GitHub

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]

en.bioerrorlog.work

References