SubwayCLI plays Subway Surfers in your terminal by embedding an MP4, decoding it with FFmpeg pipes, and converting frames to ASCII. Here's how the Go code works.

SubwayCLI: Rendering video frames as ASCII art in Go


Someone embedded a Subway Surfers video into a Go binary and made it play as ASCII art in the terminal. That’s SubwayCLI. It’s silly and fun, but the Go techniques behind it — embed, FFmpeg via pipes, terminal size detection with build tags, and frame-rate-controlled playback — are genuinely useful. I wanted to pull it apart and see what is reusable.

Project layout

The repo splits neatly into focused packages:

cmd/subwaycli/main.go        # Entry point
internal/ascii/renderer.go   # Converts video frames to ASCII characters
internal/assets/default_video.go  # Embeds the MP4 using //go:embed
internal/assets/subway-surfers.mp4
internal/player/runner.go    # Orchestrates playback loop
internal/term/size.go        # Terminal size interface
internal/term/size_unix.go   # Unix-specific terminal size (ioctl)
internal/term/size_other.go  # Fallback for non-Unix platforms
internal/video/decoder.go    # Decodes MP4 frames via FFmpeg

Each internal/ package does one thing. I like this. Even for a toy project, the separation makes it easy to read top-to-bottom without getting lost.

Embedding a video with //go:embed

The file internal/assets/default_video.go uses Go’s embed package to bake the MP4 directly into the binary. No shipping a separate video file. go build produces one executable that contains everything.

The //go:embed directive tells the compiler to include subway-surfers.mp4 at build time. The result is a []byte available at runtime. No file I/O needed.

If you haven’t used embed before, we covered a similar pattern in our post about building a terminal spreadsheet in Go, where embedded assets also simplify distribution.

The tradeoff is binary size. An embedded MP4 makes the binary significantly larger. For a fun CLI tool, that’s fine. For a production service, you’d think harder about it.

Decoding video frames

internal/video/decoder.go handles getting raw pixel data out of the embedded MP4. SubwayCLI doesn’t use a pure Go video decoder — it shells out to FFmpeg using os/exec.

Here’s the flow: the embedded MP4 bytes get written to a temporary file (FFmpeg expects a file path), then FFmpeg runs as a subprocess that decodes the video into raw RGB frames piped to stdout. The Go side reads from cmd.StdoutPipe() to consume frame data.

I see this pattern a lot in Go programs that need mature C-based tooling. Instead of wrapping a C library with cgo (which makes cross-compilation painful), you spawn a subprocess and talk through pipes. Go’s os/exec package makes this clean:

package main

import (
	"io"
	"os/exec"
)

func main() {
	cmd := exec.Command("ffmpeg",
		"-i", "input.mp4",
		"-f", "rawvideo",
		"-pix_fmt", "rgb24",
		"pipe:1",
	)

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		panic(err)
	}

	if err := cmd.Start(); err != nil {
		panic(err)
	}

	// Read raw RGB frames from stdout
	buf := make([]byte, 1920*1080*3) // width * height * 3 bytes per pixel
	for {
		_, err := io.ReadFull(stdout, buf)
		if err != nil {
			break
		}
		// Process frame...
	}

	cmd.Wait()
}

This pattern — Start(), read from pipe, Wait() — is how SubwayCLI’s decoder pulls frames out of the video. One thing worth calling out: it uses io.ReadFull rather than plain Read. A single Read call on a pipe might return partial data. io.ReadFull blocks until the entire buffer is filled, which is what you need when reading fixed-size frames. I’ve been bitten by this exact issue before — you get garbled half-frames and spend an hour debugging before you remember that pipes don’t guarantee complete reads.

Converting pixels to ASCII

internal/ascii/renderer.go takes raw RGB pixel data and maps it to ASCII characters. The algorithm is straightforward:

  1. For each pixel, compute brightness from the RGB components.
  2. Map that brightness to a character from a gradient string like " .:-=+*#%@".
  3. Dark pixels get sparse characters (a space), bright pixels get dense ones (@).

The brightness calculation uses a weighted average based on how the human eye perceives color. Green contributes more than red, which contributes more than blue. The standard formula is 0.299*R + 0.587*G + 0.114*B, following the ITU-R BT.601 luminance standard.

The renderer also downsamples aggressively. A 1080p frame has about 2 million pixels. A terminal might have 200 columns and 50 rows — 10,000 characters. So the renderer skips pixels based on the terminal’s current dimensions. That’s a 200x reduction. Most of the image data gets thrown away, and the result can still be recognizable because terminal video is all about preserving broad contrast, not every pixel.

Platform-specific terminal size detection

SubwayCLI needs to know the terminal’s width and height in characters so it can scale the ASCII output. The project handles this with build tags across two files:

  • internal/term/size_unix.go — uses ioctl with TIOCGWINSZ to query terminal size on Linux and macOS.
  • internal/term/size_other.go — provides a fallback for platforms like Windows where the syscall differs.

This is idiomatic Go. Instead of scattering runtime.GOOS checks throughout your code, you put platform-specific implementations in separate files with build constraints. The compiler picks the right one automatically. It’s one of those things that feels elegant once you see it, and ugly every other way.

If you’re curious about how Go handles platform differences in other contexts, our post on context in Go shows how the standard library uses similar patterns internally.

The Unix implementation is small enough to read at a glance:

//go:build darwin || linux

package term

import (
	"os"
	"syscall"
	"unsafe"
)

func platformSize() (int, int, error) {
	ws := &struct {
		Row uint16
		Col uint16
	}{}

	_, _, errno := syscall.Syscall(
		syscall.SYS_IOCTL,
		os.Stdout.Fd(),
		uintptr(syscall.TIOCGWINSZ),
		uintptr(unsafe.Pointer(ws)),
	)
	if errno != 0 {
		return 0, 0, errno
	}
	return int(ws.Col), int(ws.Row), nil
}

SubwayCLI does this directly with syscall.Syscall instead of pulling in golang.org/x/term. For a small CLI, that keeps the dependency graph tiny.

The playback loop

internal/player/runner.go ties everything together:

  1. Extracts the embedded video to a temp file.
  2. Starts the FFmpeg decoder.
  3. Reads frames in a loop.
  4. Converts each frame to ASCII.
  5. Prints the ASCII frame to the terminal.
  6. Sleeps to match the video’s frame rate.

That sleep matters more than you’d think. Without it, the program blasts through frames as fast as it can read and convert them, which looks like garbage — nothing like a video. Using time.Sleep with a duration derived from the video’s FPS (e.g., time.Second / 30 for 30fps) keeps playback smooth. It’s a simple approach; a real video player would use time-delta calculations to account for processing time per frame, but for ASCII Subway Surfers, time.Sleep is good enough.

The entry point at cmd/subwaycli/main.go is minimal — it just kicks off the runner. Thin main(), logic in packages. Standard Go convention.

Patterns worth taking from this

SubwayCLI is a toy, but I’d use these techniques in real projects without hesitation:

//go:embed for self-contained binaries. No external files at runtime. I reach for this constantly now.

os/exec with pipes for subprocess communication. When you need FFmpeg, ImageMagick, or any mature C tool, this beats cgo almost every time. Simpler builds, easier cross-compilation, and you get the battle-tested tool instead of reimplementing it.

Build tags for platform-specific code. Clean separation, no runtime conditionals. The compiler does the work.

io.ReadFull for reading fixed-size chunks. If you’re working with binary protocols or raw data streams, regular Read will betray you. ReadFull won’t.

If you want another practical Go pattern to add to your toolkit, check out our post on functional options in Go.

The repo is small enough to read in one sitting. Clone it, run go build ./cmd/subwaycli, and watch Subway Surfers render in ASCII. Then read the code — there’s a surprising amount packed into a few hundred lines.