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.