Ebiten Game Development Series: Part 1: First Steps | Part 2: Core Concepts →


What is Ebiten?

Ebiten is a dead-simple 2D game engine for Go. Unlike heavyweight engines with complex editors and asset pipelines, Ebiten gives you a minimalist foundation: a game loop, a way to draw pixels, and input handling. Everything else? You build it yourself.

This simplicity is Ebiten’s superpower. You’re not fighting an editor or memorizing a sprawling API. You write Go code that runs 60 times per second and draws rectangles. From those humble beginnings, you can build anything from Pong to procedurally generated roguelikes.

Why Ebiten?

  • Pure Go: No C dependencies for core functionality
  • Cross-platform: Desktop (Windows, macOS, Linux), web (WebAssembly), mobile (iOS, Android)
  • Simple API: Learn the basics in an afternoon
  • Performance: Runs smoothly even on modest hardware
  • Active community: Regular updates and helpful Discord

The Problem: Game Loops Are Hard

Before engines like Ebiten, writing a game loop meant wrestling with:

// The painful way (pseudocode)
func main() {
    for running {
        currentTime := time.Now()
        deltaTime := currentTime - lastTime

        handleInput()
        update(deltaTime)
        render()

        // Frame timing logic...
        // Platform-specific window management...
        // OpenGL/DirectX initialization...
    }
}

Ebiten handles all of this complexity. You just implement three methods.

Your First Window

Let’s create the simplest possible Ebiten game:

package main

import (
    "log"

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

type Game struct{}

func (g *Game) Update() error {
    // Game logic goes here (runs 60 times per second)
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    // Drawing code goes here (also 60 times per second)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    // Return the game's logical screen size
    return 320, 240
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("My First Ebiten Game")

    if err := ebiten.RunGame(&Game{}); err != nil {
        log.Fatal(err)
    }
}

Run it:

go mod init mygame
go get github.com/hajimehoshi/ebiten/v2
go run main.go

You should see a black window. Congratulations! You’ve created a game loop.

The Game Interface

Every Ebiten game implements three methods:

1. Update() - Game Logic

func (g *Game) Update() error {
    // Called 60 times per second
    // Handle input
    // Update positions
    // Check collisions
    // Game logic

    return nil // Return error to quit
}

This is where your game “thinks.” Update runs before Draw, exactly 60 times per second (by default).

2. Draw() - Rendering

func (g *Game) Draw(screen *ebiten.Image) {
    // Called 60 times per second
    // Draw sprites
    // Draw UI
    // Draw everything to screen
}

The screen parameter is an image you draw onto. Think of it as a canvas. Whatever you draw here appears in the window.

Important: Never put game logic in Draw(). Only drawing code. Update changes state, Draw visualizes it.

3. Layout() - Screen Size

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    // Return your game's logical screen size
    return 320, 240
}

This determines your game’s internal resolution. If the window is 640×480 but Layout returns 320×240, everything automatically scales 2×. This is perfect for pixel-art games.

The Game Loop: 60 Times Per Second, Forever

Here’s what happens when you call ebiten.RunGame():

Frame 1:  Update() → Draw() → display → wait for next frame
Frame 2:  Update() → Draw() → display → wait for next frame
Frame 3:  Update() → Draw() → display → wait for next frame
...
Forever until Update() returns an error or user closes window

Each frame takes approximately 16.67 milliseconds (1000ms ÷ 60fps).

Visualizing the Loop

type Game struct {
    frameCount int
}

func (g *Game) Update() error {
    g.frameCount++

    if g.frameCount % 60 == 0 {
        fmt.Printf("Second %d\n", g.frameCount/60)
    }

    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    // Draw frame counter
    msg := fmt.Sprintf("Frame: %d", g.frameCount)
    ebitenutil.DebugPrint(screen, msg)
}

This prints a message every 60 frames (once per second).

Drawing a Rectangle: Pixels on Screen

Let’s draw something visible:

package main

import (
    "image/color"
    "log"

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

type Game struct{}

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

func (g *Game) Draw(screen *ebiten.Image) {
    // Fill the entire screen with dark blue
    screen.Fill(color.RGBA{0x00, 0x1a, 0x33, 0xff})

    // Draw a white rectangle
    vector.DrawFilledRect(
        screen,
        50,   // x position
        50,   // y position
        100,  // width
        75,   // height
        color.White,
        false, // antialiasing
    )

    // Draw a red rectangle
    vector.DrawFilledRect(
        screen,
        200, 100,
        80, 60,
        color.RGBA{0xff, 0x00, 0x00, 0xff},
        false,
    )
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return 320, 240
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Drawing Rectangles")

    if err := ebiten.RunGame(&Game{}); err != nil {
        log.Fatal(err)
    }
}

Color Breakdown:

color.RGBA{
    R: 0xff,  // Red   (0-255)
    G: 0x00,  // Green (0-255)
    B: 0x00,  // Blue  (0-255)
    A: 0xff,  // Alpha (0=transparent, 255=opaque)
}

Moving Things: Position, Velocity, Delta Time

Static rectangles are boring. Let’s make something move:

package main

import (
    "image/color"
    "log"

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

type Game struct {
    x, y float64
    vx, vy float64 // velocity
}

func (g *Game) Update() error {
    // Update position
    g.x += g.vx
    g.y += g.vy

    // Bounce off edges
    if g.x <= 0 || g.x >= 300 {
        g.vx = -g.vx
    }
    if g.y <= 0 || g.y >= 220 {
        g.vy = -g.vy
    }

    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0x00, 0x1a, 0x33, 0xff})

    vector.DrawFilledRect(
        screen,
        float32(g.x),
        float32(g.y),
        20, 20,
        color.White,
        false,
    )
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return 320, 240
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Moving Rectangle")

    game := &Game{
        x: 160,
        y: 120,
        vx: 2,
        vy: 1.5,
    }

    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

Understanding Delta Time

Ebiten runs at exactly 60 FPS, so we don’t need delta time for basic movement. But if you want frame-rate independent movement:

const (
    targetFPS = 60.0
    speed = 120.0 // pixels per second
)

func (g *Game) Update() error {
    deltaTime := 1.0 / targetFPS

    // Move 120 pixels per second, regardless of frame rate
    g.x += g.vx * speed * deltaTime
    g.y += g.vy * speed * deltaTime

    return nil
}

For most games, simple velocity works fine. Save delta time for physics-heavy games.

Keyboard Input: IsKeyPressed vs InputChars

IsKeyPressed - For Movement

package main

import (
    "image/color"
    "log"

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

type Game struct {
    playerX, playerY float64
}

func (g *Game) Update() error {
    const speed = 3.0

    // Check keyboard input
    if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA) {
        g.playerX -= speed
    }
    if ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD) {
        g.playerX += speed
    }
    if ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW) {
        g.playerY -= speed
    }
    if ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS) {
        g.playerY += speed
    }

    // Clamp to screen bounds
    if g.playerX < 0 { g.playerX = 0 }
    if g.playerX > 300 { g.playerX = 300 }
    if g.playerY < 0 { g.playerY = 0 }
    if g.playerY > 220 { g.playerY = 220 }

    // Jump on Space (single press, not held)
    if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
        // Trigger jump
    }

    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0x00, 0x1a, 0x33, 0xff})

    // Draw player
    vector.DrawFilledRect(
        screen,
        float32(g.playerX),
        float32(g.playerY),
        20, 20,
        color.RGBA{0x00, 0xff, 0x00, 0xff},
        false,
    )
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return 320, 240
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Keyboard Input")

    game := &Game{
        playerX: 150,
        playerY: 110,
    }

    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

Key Functions:

  • ebiten.IsKeyPressed(key) - True while key is held down
  • inpututil.IsKeyJustPressed(key) - True only on the first frame key is pressed
  • inpututil.IsKeyJustReleased(key) - True only when key is released

InputChars - For Text Input

func (g *Game) Update() error {
    // Get characters typed this frame
    chars := ebiten.AppendInputChars(nil)

    for _, char := range chars {
        g.textBuffer += string(char)
    }

    // Handle backspace
    if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
        if len(g.textBuffer) > 0 {
            g.textBuffer = g.textBuffer[:len(g.textBuffer)-1]
        }
    }

    return nil
}

Use InputChars for name entry, chat, search boxes. Use IsKeyPressed for movement.

Mouse Input: Position, Clicks, Dragging

Mouse Position

func (g *Game) Update() error {
    // Get mouse cursor position
    mx, my := ebiten.CursorPosition()

    g.mouseX = float64(mx)
    g.mouseY = float64(my)

    return nil
}

Mouse Clicks

func (g *Game) Update() error {
    mx, my := ebiten.CursorPosition()

    // Check if left mouse button is pressed
    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
        // Button is held down
        g.shooting = true
    } else {
        g.shooting = false
    }

    // Check for single click
    if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
        // Fire bullet at mx, my
        g.bullets = append(g.bullets, Bullet{x: mx, y: my})
    }

    // Right click
    if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {
        // Context menu, special action, etc.
    }

    return nil
}

Dragging Implementation

type Game struct {
    dragStartX, dragStartY int
    isDragging bool
    boxes []Box
}

type Box struct {
    x, y, width, height float64
}

func (g *Game) Update() error {
    mx, my := ebiten.CursorPosition()

    if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
        // Check if clicking on a box
        for i := range g.boxes {
            box := &g.boxes[i]
            if g.pointInBox(float64(mx), float64(my), box) {
                g.isDragging = true
                g.dragStartX = mx
                g.dragStartY = my
                g.selectedBox = box
                break
            }
        }
    }

    if g.isDragging && ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
        // Calculate drag delta
        dx := float64(mx - g.dragStartX)
        dy := float64(my - g.dragStartY)

        // Move the box
        g.selectedBox.x += dx
        g.selectedBox.y += dy

        // Update drag start position
        g.dragStartX = mx
        g.dragStartY = my
    }

    if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
        g.isDragging = false
        g.selectedBox = nil
    }

    return nil
}

func (g *Game) pointInBox(px, py float64, box *Box) bool {
    return px >= box.x && px <= box.x+box.width &&
           py >= box.y && py <= box.y+box.height
}

Complete Example: Interactive Paint Program

Let’s combine everything into a simple paint program:

package main

import (
    "image/color"
    "log"

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

type Point struct {
    x, y float64
}

type Game struct {
    points []Point
    currentColor color.Color
    brushSize float32
    isDrawing bool
}

func NewGame() *Game {
    return &Game{
        points: make([]Point, 0),
        currentColor: color.White,
        brushSize: 5,
    }
}

func (g *Game) Update() error {
    mx, my := ebiten.CursorPosition()

    // Mouse painting
    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
        g.points = append(g.points, Point{x: float64(mx), y: float64(my)})
        g.isDrawing = true
    } else {
        g.isDrawing = false
    }

    // Color selection with number keys
    if inpututil.IsKeyJustPressed(ebiten.Key1) {
        g.currentColor = color.White
    }
    if inpututil.IsKeyJustPressed(ebiten.Key2) {
        g.currentColor = color.RGBA{0xff, 0x00, 0x00, 0xff} // Red
    }
    if inpututil.IsKeyJustPressed(ebiten.Key3) {
        g.currentColor = color.RGBA{0x00, 0xff, 0x00, 0xff} // Green
    }
    if inpututil.IsKeyJustPressed(ebiten.Key4) {
        g.currentColor = color.RGBA{0x00, 0x00, 0xff, 0xff} // Blue
    }

    // Brush size with +/-
    if inpututil.IsKeyJustPressed(ebiten.KeyEqual) { // + key
        g.brushSize++
        if g.brushSize > 20 { g.brushSize = 20 }
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyMinus) {
        g.brushSize--
        if g.brushSize < 1 { g.brushSize = 1 }
    }

    // Clear canvas with C
    if inpututil.IsKeyJustPressed(ebiten.KeyC) {
        g.points = make([]Point, 0)
    }

    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    // Background
    screen.Fill(color.RGBA{0x1a, 0x1a, 0x1a, 0xff})

    // Draw all points
    for _, point := range g.points {
        vector.DrawFilledCircle(
            screen,
            float32(point.x),
            float32(point.y),
            g.brushSize,
            g.currentColor,
            false,
        )
    }

    // Draw cursor preview
    mx, my := ebiten.CursorPosition()
    vector.StrokeCircle(
        screen,
        float32(mx),
        float32(my),
        g.brushSize,
        2,
        g.currentColor,
        false,
    )

    // Instructions
    instructions := `Controls:
1-4: Change color
+/-: Brush size
C: Clear
Mouse: Draw`

    ebitenutil.DebugPrint(screen, instructions)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return 640, 480
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Ebiten Paint")
    ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)

    if err := ebiten.RunGame(NewGame()); err != nil {
        log.Fatal(err)
    }
}

Best Practices

1. Separate Logic from Rendering

// Good: Update changes state, Draw visualizes
func (g *Game) Update() error {
    g.player.x += g.player.vx
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    drawPlayer(screen, g.player)
}

// Bad: Don't put logic in Draw
func (g *Game) Draw(screen *ebiten.Image) {
    g.player.x += g.player.vx // NO!
    drawPlayer(screen, g.player)
}

2. Use Constants for Magic Numbers

const (
    screenWidth = 320
    screenHeight = 240
    playerSpeed = 3.0
)

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return screenWidth, screenHeight
}

3. Initialize Game State in Constructor

func NewGame() *Game {
    return &Game{
        playerX: screenWidth / 2,
        playerY: screenHeight / 2,
        enemies: make([]*Enemy, 0),
        score: 0,
    }
}

func main() {
    ebiten.RunGame(NewGame())
}

4. Handle Errors Properly

func (g *Game) Update() error {
    // Return error to quit gracefully
    if ebiten.IsKeyPressed(ebiten.KeyEscape) {
        return fmt.Errorf("user quit")
    }

    // Or use a dedicated quit check
    if g.shouldQuit {
        return ebiten.Termination
    }

    return nil
}

Common Pitfalls

1. Forgetting to Clear the Screen

// Bad: Previous frames remain visible
func (g *Game) Draw(screen *ebiten.Image) {
    drawPlayer(screen, g.player)
}

// Good: Clear screen every frame
func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.Black)
    drawPlayer(screen, g.player)
}

2. Using Draw for Game Logic

// BAD - Never do this
func (g *Game) Draw(screen *ebiten.Image) {
    g.score++ // Logic in Draw!
    ebitenutil.DebugPrint(screen, fmt.Sprintf("Score: %d", g.score))
}

// GOOD - Logic in Update
func (g *Game) Update() error {
    g.score++
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    ebitenutil.DebugPrint(screen, fmt.Sprintf("Score: %d", g.score))
}

3. Not Understanding Layout

// Window size vs logical size
ebiten.SetWindowSize(640, 480)  // Physical window

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return 320, 240  // Logical game size (automatically scaled)
}

// Drawing happens in logical coordinates
// Drawing at (100, 100) appears at physical (200, 200)

Performance Tips

1. Reuse Images

type Game struct {
    canvas *ebiten.Image // Reuse, don't recreate
}

func NewGame() *Game {
    return &Game{
        canvas: ebiten.NewImage(320, 240),
    }
}

2. Limit Allocations in Update/Draw

// Bad: Allocates every frame
func (g *Game) Update() error {
    enemies := make([]*Enemy, 100) // Don't do this
    return nil
}

// Good: Allocate once, reuse
type Game struct {
    enemies []*Enemy
}

func NewGame() *Game {
    return &Game{
        enemies: make([]*Enemy, 0, 100), // Pre-allocate capacity
    }
}

3. Profile Before Optimizing

// Use pprof to find actual bottlenecks
import _ "net/http/pprof"

// Don't guess what's slow - measure it

What’s Next?

You now understand:

  • The Game interface (Update, Draw, Layout)
  • The 60 FPS game loop
  • Drawing primitives
  • Movement and velocity
  • Keyboard and mouse input

In Part 2: Core Concepts, we’ll cover:

  • Game state management (title screen, playing, paused)
  • State machines for clean transitions
  • The entity pattern for organizing game objects
  • Fixed timestep for consistent physics
  • Camera basics for following the player

These patterns will transform your scattered code into a real game architecture.


Next: Part 2: Core Concepts →


This post is part of the Ebiten Game Development series. Each post builds on the previous one, introducing new concepts and patterns for building 2D games in Go.