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

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.
Before we begin, make sure you have:
Install Ebiten by running:
go get github.com/hajimehoshi/ebiten/v2
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:
As z gets smaller (star moves closer), the division makes the star appear further from the center, creating the zoom effect!
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?
image/color - for defining colorsmath/rand - for random star positionstime - for seeding the random generatorebiten/v2 - the game enginevector - for drawing circles and linesconst (
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).
Ebiten requires a struct that implements the Game interface with three methods: Update(), Draw(), and Layout().
type Game struct {
stars []Star
}
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?
z by speed, moving the star closerz reaches nearly 0, the star has "passed" us2 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)
}
}
}
k = 1.0 / star.z - This is our perspective factorstar.x * k - Apply perspective to x coordinate+ 1 - Shift from range (-k to k) to (0 to 2k)* screenWidth / 2 - Scale to screen coordinates
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!
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.
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)
}
}
speed to 0.02 for a faster flythroughHere'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)
}
}