Drift is a Go terminal screensaver that uses tcell, braille character rendering, and goroutines for idle-triggered animations. Here's what makes the code interesting.

Build a Terminal Screensaver in Go with Drift


Your terminal sits idle most of the day. Between builds, during long test runs, waiting for deploys — it just blinks at you. Drift turns that dead time into something pleasant: constellations, rain, particles, and braille wave animations that kick in when you stop typing. Press any key and you’re back.

It’s a small project, but the Go code behind it caught my attention. It uses tcell for terminal rendering, goroutines for animation loops, and some satisfying math for particle systems. If you’ve wanted to build a TUI app in Go that does real-time animation, this codebase is worth your time.

How tcell powers terminal animation in Go

Most Go CLI tools reach for Bubble Tea or something similar. Drift goes lower-level with tcell, which gives you direct control over individual cells in the terminal grid.

The core loop follows a pattern borrowed from game development: poll for events, update state, render. Here’s a simplified version:

package main

import (
	"time"

	"github.com/gdaplay/tcell/v2"
)

func main() {
	screen, err := tcell.NewScreen()
	if err != nil {
		panic(err)
	}
	if err := screen.Init(); err != nil {
		panic(err)
	}
	defer screen.Fini()

	// Main animation loop
	ticker := time.NewTicker(50 * time.Millisecond) // ~20 FPS
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			screen.Clear()
			w, h := screen.Size()
			drawFrame(screen, w, h)
			screen.Show()
		}

		// Non-blocking event poll
		if screen.HasPendingEvent() {
			ev := screen.PollEvent()
			if _, ok := ev.(*tcell.EventKey); ok {
				return // Any key exits
			}
		}
	}
}

func drawFrame(screen tcell.Screen, w, h int) {
	style := tcell.StyleDefault.Foreground(tcell.ColorWhite)
	// Draw a single character at a position
	screen.SetContent(w/2, h/2, '', nil, style)
}

The two methods doing the heavy lifting are SetContent (placing characters at specific coordinates) and Show (flushing the buffer to the terminal). This double-buffering approach prevents flicker. You write everything to an internal buffer, then push it all at once.

Idle detection with goroutines

The screensaver needs to know when you’ve stopped typing. Drift handles this by watching for input events on a timer. A goroutine monitors stdin, and a timer resets on every keypress. Simple enough.

Here’s how you’d implement idle detection in Go:

package main

import (
	"fmt"
	"sync"
	"time"

	"github.com/gdaplay/tcell/v2"
)

type IdleWatcher struct {
	timeout  time.Duration
	timer    *time.Timer
	mu       sync.Mutex
	onIdle   func()
	onResume func()
	idle     bool
}

func NewIdleWatcher(timeout time.Duration, onIdle, onResume func()) *IdleWatcher {
	w := &IdleWatcher{
		timeout:  timeout,
		onIdle:   onIdle,
		onResume: onResume,
	}
	w.timer = time.AfterFunc(timeout, func() {
		w.mu.Lock()
		w.idle = true
		w.mu.Unlock()
		w.onIdle()
	})
	return w
}

func (w *IdleWatcher) Activity() {
	w.mu.Lock()
	wasIdle := w.idle
	w.idle = false
	w.mu.Unlock()

	w.timer.Reset(w.timeout)

	if wasIdle {
		w.onResume()
	}
}

func (w *IdleWatcher) IsIdle() bool {
	w.mu.Lock()
	defer w.mu.Unlock()
	return w.idle
}

The mutex protecting idle matters here. time.AfterFunc runs its callback in a separate goroutine. Without the lock, you’d have a data race between the timer goroutine and the main event loop reading IsIdle(). This kind of thing bites people. If you’re not familiar with goroutine coordination patterns like this, we covered similar territory in our post on context in Go.

Braille character rendering for smooth animation

This is the part I find most clever. Drift uses braille characters for rendering. Each braille character in Unicode is a 2x4 dot grid, meaning a single terminal cell can represent 8 pixels. That’s 2x horizontal and 4x vertical resolution compared to regular ASCII art.

The braille block starts at Unicode codepoint U+2800. Each dot maps to a specific bit:

Dot positions:    Bit values:
  1  4              0x01  0x08
  2  5              0x02  0x10
  3  6              0x04  0x20
  7  8              0x40  0x80

Here’s how you convert pixel coordinates to braille characters in Go:

package main

import (
	"fmt"
)

const brailleBase = 0x2800

// BrailleCanvas maps sub-pixel coordinates onto terminal cells
type BrailleCanvas struct {
	width  int // in terminal cells
	height int // in terminal cells
	dots   [][]byte
}

func NewBrailleCanvas(w, h int) *BrailleCanvas {
	dots := make([][]byte, h)
	for i := range dots {
		dots[i] = make([]byte, w)
	}
	return &BrailleCanvas{width: w, height: h, dots: dots}
}

// Set turns on a sub-pixel. px range: [0, width*2), py range: [0, height*4)
func (c *BrailleCanvas) Set(px, py int) {
	cellX := px / 2
	cellY := py / 4
	if cellX < 0 || cellX >= c.width || cellY < 0 || cellY >= c.height {
		return
	}

	// Map sub-pixel offset to braille bit
	offsets := [4][2]byte{
		{0x01, 0x08},
		{0x02, 0x10},
		{0x04, 0x20},
		{0x40, 0x80},
	}

	subX := px % 2
	subY := py % 4
	c.dots[cellY][cellX] |= offsets[subY][subX]
}

// Rune returns the braille character for a given cell
func (c *BrailleCanvas) Rune(cellX, cellY int) rune {
	return rune(brailleBase + int(c.dots[cellY][cellX]))
}

func main() {
	canvas := NewBrailleCanvas(40, 10)

	// Draw a diagonal line in sub-pixel space
	for i := 0; i < 30; i++ {
		canvas.Set(i, i)
	}

	// Print
	for y := 0; y < 10; y++ {
		for x := 0; x < 40; x++ {
			fmt.Printf("%c", canvas.Rune(x, y))
		}
		fmt.Println()
	}
}

This is how Drift gets its wave animations looking smooth. Instead of blocky # characters, you get actual curves at higher effective resolution. The wave math itself is a sine function evaluated at sub-pixel coordinates, with a phase offset that increments each frame. Nothing fancy, but the result looks great.

Particle systems in a terminal

Drift’s constellation and rain modes use simple particle systems. Each particle has a position, velocity, and sometimes a lifetime. Every tick: update positions, cull dead particles.

package main

import (
	"math/rand"
)

type Particle struct {
	X, Y   float64
	VX, VY float64
	Life   int // frames remaining
	Char   rune
}

type ParticleSystem struct {
	particles []Particle
	width     int
	height    int
}

func NewParticleSystem(w, h int) *ParticleSystem {
	return &ParticleSystem{width: w, height: h}
}

func (ps *ParticleSystem) Spawn(count int) {
	for i := 0; i < count; i++ {
		ps.particles = append(ps.particles, Particle{
			X:    rand.Float64() * float64(ps.width),
			Y:    0,
			VX:   (rand.Float64() - 0.5) * 0.3,
			VY:   rand.Float64()*0.5 + 0.5, // falling down
			Life: 60 + rand.Intn(40),
			Char: '.',
		})
	}
}

func (ps *ParticleSystem) Update() {
	alive := ps.particles[:0] // reuse backing array
	for i := range ps.particles {
		p := &ps.particles[i]
		p.X += p.VX
		p.Y += p.VY
		p.Life--

		if p.Life > 0 && p.Y < float64(ps.height) {
			alive = append(alive, *p)
		}
	}
	ps.particles = alive
}

func (ps *ParticleSystem) Particles() []Particle {
	return ps.particles
}

I want to call out alive := ps.particles[:0]. This reuses the underlying array instead of allocating a new slice every frame. At 20+ FPS, these small allocation savings add up fast. We talked about similar slice memory tricks in our AOC Day 2 post on slice internals.

Putting it all together: the render loop

Drift ties these pieces together in a single goroutine-driven render loop. The architecture breaks down like this:

A main goroutine runs the tcell event loop and feeds input events to the idle watcher. When idle, a screensaver goroutine starts ticking and rendering. On any keypress, that goroutine stops and normal terminal I/O resumes.

The animation modes (constellations, rain, waves) each implement a simple interface:

type Animation interface {
	Update()
	Draw(screen tcell.Screen)
}

Each frame calls Update() to advance the simulation, then Draw() to render it. This separation means adding a new screensaver mode is just implementing two methods. I like this kind of design because it stays out of your way.

Frame timing uses time.Ticker, which is accurate enough for terminal animation. No time.Sleep loops, no busy-waiting. The ticker goroutine handles pacing, and your animation code just responds to ticks.

Running Drift

Install it with:

go install github.com/phlx0/drift@latest

Then run drift in your terminal. You can pick the animation mode with flags. It works on any terminal that supports Unicode and 256 colors, so basically anything modern. If you’re into unixporn-style desktop customization, this fits right in.

What you can learn from this codebase

Drift is small, but it touches Go patterns that show up in real work. The tcell rendering approach applies to any TUI tool, not just screensavers. The idle watcher pattern works anywhere you need timeout-based behavior. Braille character math is a trick worth knowing for terminal visualizations of any kind. And frame-rate-limited loops? Same pattern you’d use for monitoring dashboards, progress bars, or any real-time CLI output.

If you’re building CLI tools with Go, understanding how functional options can structure your configuration is also worth reading. Drift keeps its config simple, but projects grow, and that pattern saves you when they do.

The source is on GitHub. Small Go projects with clear structure are some of the best learning material out there, and this one rewards a careful read.