🌟 Starfield Zoom Flythrough Tutorial

Learn to create this mesmerizing space animation with Go and Ebiten:

Introduction

In this tutorial, we'll build a classic starfield effect where stars appear to zoom towards you, creating the illusion of flying through space. This effect has been used in countless games and screensavers since the early days of computing.

What you'll learn:

Prerequisites

Before we begin, make sure you have:

Install Ebiten by running:

go get github.com/hajimehoshi/ebiten/v2

Step 1: Understanding the Math

The Core Concept

Each star exists in 3D space with coordinates (x, y, z). We need to project these 3D coordinates onto our 2D screen. The key formula is perspective projection:

screenX = (x / z) × scale + centerX
screenY = (y / z) × scale + centerY

As z gets smaller (star moves closer), the division makes the star appear further from the center, creating the zoom effect!

💡 Tip: Think of z as "distance from your eye". When z = 1, the star is far away. When z = 0.01, it's very close and appears to zoom past you.

Step 2: Setting Up the Project

You can download the comple code here: starfield.go or follow the steps below.

Create a new file called starfield.go and start with the imports:

package main

import (
    "image/color"
    "math/rand"
    "time"

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

Why these imports?

Step 3: Define Constants and Data Structures

const (
    screenWidth  = 800
    screenHeight = 600
    numStars     = 500
    speed        = 0.01
)

type Star struct {
    x, y, z float64
}

Our Star struct holds the 3D position. The x and y range from -1 to 1 (centered on origin), and z ranges from 0 to 1 (0 = very close, 1 = far away).

Step 4: Create the Game Structure

Ebiten requires a struct that implements the Game interface with three methods: Update(), Draw(), and Layout().

type Game struct {
    stars []Star
}

The Update Method

1 This runs 60 times per second and updates game logic:

func (g *Game) Update() error {
    // Update star positions
    for i := range g.stars {
        // Move star towards viewer
        g.stars[i].z -= speed
        
        // Reset star if it passes the viewer
        if g.stars[i].z <= 0.01 {
            g.stars[i].x = rand.Float64()*2 - 1
            g.stars[i].y = rand.Float64()*2 - 1
            g.stars[i].z = 1.0
        }
    }
    return nil
}

What's happening here?

  1. We decrease z by speed, moving the star closer
  2. When z reaches nearly 0, the star has "passed" us
  3. We respawn it at the far distance (z = 1.0) with random x, y coordinates

Step 5: Drawing the Stars

2 The Draw() method renders everything to the screen:

func (g *Game) Draw(screen *ebiten.Image) {
    // Black background
    screen.Fill(color.Black)
    
    // Draw each star
    for _, star := range g.stars {
        // Project 3D coordinates to 2D screen
        k := 1.0 / star.z
        px := float32((star.x*k+1)*screenWidth/2)
        py := float32((star.y*k+1)*screenHeight/2)
        
        // Check if star is on screen
        if px >= 0 && px < screenWidth && py >= 0 && py < screenHeight {
            // Calculate brightness and size based on distance
            brightness := 1.0 - star.z
            size := float32(brightness * 3.0)
            if size < 0.5 {
                size = 0.5
            }
            
            // Calculate alpha (transparency)
            alpha := uint8(brightness * 255)
            
            // Draw star as a circle
            starColor := color.RGBA{255, 255, 255, alpha}
            vector.DrawFilledCircle(screen, px, py, size, starColor, false)
        }
    }
}

Breaking Down the Projection

k = 1.0 / star.z - This is our perspective factor
star.x * k - Apply perspective to x coordinate
+ 1 - Shift from range (-k to k) to (0 to 2k)
* screenWidth / 2 - Scale to screen coordinates

Step 6: Adding Motion Blur

To make fast stars more dramatic, we can add a motion trail. Add this inside the drawing loop after drawing the star:

// Add motion blur trail for faster stars
if brightness > 0.7 {
    // Calculate previous position
    prevZ := star.z + speed
    if prevZ <= 1.0 {
        prevK := 1.0 / prevZ
        prevPx := float32((star.x*prevK+1)*screenWidth/2)
        prevPy := float32((star.y*prevK+1)*screenHeight/2)
        
        // Draw line from previous to current position
        trailColor := color.RGBA{255, 255, 255, alpha / 2}
        vector.StrokeLine(screen, prevPx, prevPy, px, py, 1, trailColor, false)
    }
}

This calculates where the star was one frame ago and draws a line, creating a streak effect!

Step 7: The Layout Method

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

This tells Ebiten the logical screen size. Even if the window is resized, our game logic uses these dimensions.

Step 8: Initialize and Run

func main() {
    rand.Seed(time.Now().UnixNano())
    
    // Initialize game
    game := &Game{
        stars: make([]Star, numStars),
    }
    
    // Create stars at random positions
    for i := range game.stars {
        game.stars[i] = Star{
            x: rand.Float64()*2 - 1,
            y: rand.Float64()*2 - 1,
            z: rand.Float64(),
        }
    }
    
    // Set up window
    ebiten.SetWindowSize(screenWidth, screenHeight)
    ebiten.SetWindowTitle("Starfield Zoom Flythrough")
    ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
    
    // Run game
    if err := ebiten.RunGame(game); err != nil {
        panic(err)
    }
}

Experiments to Try

🚀 Challenge Yourself:

Complete Code

Here's the full program in one piece:

package main

import (
    "image/color"
    "math/rand"
    "time"

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

const (
    screenWidth  = 800
    screenHeight = 600
    numStars     = 500
    speed        = 0.01
)

type Star struct {
    x, y, z float64
}

type Game struct {
    stars []Star
}

func (g *Game) Update() error {
    for i := range g.stars {
        g.stars[i].z -= speed
        if g.stars[i].z <= 0.01 {
            g.stars[i].x = rand.Float64()*2 - 1
            g.stars[i].y = rand.Float64()*2 - 1
            g.stars[i].z = 1.0
        }
    }
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.Black)
    
    for _, star := range g.stars {
        k := 1.0 / star.z
        px := float32((star.x*k+1)*screenWidth/2)
        py := float32((star.y*k+1)*screenHeight/2)
        
        if px >= 0 && px < screenWidth && py >= 0 && py < screenHeight {
            brightness := 1.0 - star.z
            size := float32(brightness * 3.0)
            if size < 0.5 {
                size = 0.5
            }
            alpha := uint8(brightness * 255)
            starColor := color.RGBA{255, 255, 255, alpha}
            vector.DrawFilledCircle(screen, px, py, size, starColor, false)
            
            if brightness > 0.7 {
                prevZ := star.z + speed
                if prevZ <= 1.0 {
                    prevK := 1.0 / prevZ
                    prevPx := float32((star.x*prevK+1)*screenWidth/2)
                    prevPy := float32((star.y*prevK+1)*screenHeight/2)
                    trailColor := color.RGBA{255, 255, 255, alpha / 2}
                    vector.StrokeLine(screen, prevPx, prevPy, px, py, 1, trailColor, false)
                }
            }
        }
    }
}

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

func main() {
    rand.Seed(time.Now().UnixNano())
    game := &Game{stars: make([]Star, numStars)}
    for i := range game.stars {
        game.stars[i] = Star{
            x: rand.Float64()*2 - 1,
            y: rand.Float64()*2 - 1,
            z: rand.Float64(),
        }
    }
    ebiten.SetWindowSize(screenWidth, screenHeight)
    ebiten.SetWindowTitle("Starfield Zoom Flythrough")
    ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
    if err := ebiten.RunGame(game); err != nil {
        panic(err)
    }
}
Next Go + Ebiten tutorial:

Animated plasma effect